The firewall and NAT
/etc/nftables.conf holds the whole policy. It's the router ruleset from nftables and iptables on Debian, adapted for VLANs.
The forward chain: a policy table in nftables
The forward chain starts with the usual pair: let replies to existing connections back in (ct state established,related), drop invalid packets. Then one verdict map says who may start connections to whom: it looks up the pair input interface . output interface and finds a verdict. Each line is one "yes" from the policy table: staff to the internet and to the servers, guests and servers to the internet. Any pair that isn't listed falls through to the final counter and the drop policy, including any VLAN you add later, so a new VLAN is isolated until you decide otherwise.
NAT
The nat table masquerades everything leaving through the WAN: the office's private addresses are replaced by the router's public one. The web server's log confirms it: every visitor is 203.0.113.2.
The test matrix
nft -c -f checks the file, then systemctl enable --now nftables loads it and loads it again at every boot. Then each VLAN tries everything:
- Staff reach the internet and the server.
- Guests reach the internet, but not the server or a staff PC.
- Servers reach the internet, but can't start connections to staff.
The forward chain's counter shows the dropped attempts.
Protecting the router itself
The input chain is for traffic to the router, and it's stricter: replies, loopback, a few ICMP types, and SSH only from the staff VLAN. A guest can't reach SSH on its own gateway address, nor on the router's staff address: the rule matches the interface the packet arrived on, not the address it was sent to, so there's no way round it by picking another of the router's addresses. Guests can still ping their gateway, which is useful for troubleshooting.
#!/usr/sbin/nft -f
# /etc/nftables.conf on the office router.
# Check, then load: nft -c -f /etc/nftables.conf && systemctl reload nftables
flush ruleset
define WAN = "eth0"
table inet filter {
chain input {
type filter hook input priority filter; policy drop;
ct state established,related accept
ct state invalid drop
iif lo accept
icmp type { echo-request, destination-unreachable, time-exceeded } accept
iifname "staff" tcp dport 22 accept comment "SSH from staff only"
counter comment "everything else to the router"
}
chain forward {
type filter hook forward priority filter; policy drop;
ct state established,related accept
ct state invalid drop
# Who may start connections to whom: one line per allowed pair.
iifname . oifname vmap {
"staff" . $WAN : accept,
"staff" . "servers" : accept,
"guest" . $WAN : accept,
"servers" . $WAN : accept,
}
counter comment "every other pair is dropped"
}
}
table ip nat {
chain postrouting {
type nat hook postrouting priority srcnat;
oifname $WAN masquerade comment "the office shares the router's public address"
}
}- Lines 9–17: The input chain: traffic to the router itself. Replies, loopback, some ICMP, and SSH from staff only.
- Line 15: iifname matches the interface the packet arrived on, whichever of the router's addresses it was sent to.
- Lines 21–22: Replies to allowed connections, and invalid packets, are dealt with first.
- Lines 24–29: The policy table: each line is a pair (in . out) that may start connections. Every other pair falls through.
- Line 30: Counts what the drop policy is about to drop.
- Line 37: Everything leaving to the internet takes the router's public address.
root@router:~# nft -c -f /etc/nftables.conf && systemctl enable --now nftables && nft list tables
Created symlink '/etc/systemd/system/sysinit.target.wants/nftables.service' → '/usr/lib/systemd/system/nftables.service'.
table inet filter
table ip nat- Lines 1–4: Checked first, then enabled now and at every boot: the filter and nat tables are loaded.
root@staff:~# curl -s -o /dev/null -w "%{http_code}\n" -m 2 http://198.51.100.10/; curl -s -o /dev/null -w "%{http_code}\n" -m 2 http://10.10.30.10/
200
200
root@guest:~# curl -s -o /dev/null -w "%{http_code}\n" -m 2 http://198.51.100.10/; curl -s -o /dev/null -w "%{http_code}\n" -m 2 http://10.10.30.10/; ping -c 1 -W 1 10.10.10.20 | tail -2
200
000
1 packets transmitted, 0 received, 100% packet loss, time 0ms
root@server:~# curl -s -o /dev/null -w "%{http_code}\n" -m 2 http://198.51.100.10/; ping -c 1 -W 1 10.10.10.20 | tail -2
200
1 packets transmitted, 0 received, 100% packet loss, time 0ms
root@web:~# tail -3 /tmp/web.log
203.0.113.2 - - [04/Nov/2026 21:59:31] "GET / HTTP/1.1" 200 -
203.0.113.2 - - [04/Nov/2026 21:59:31] "GET / HTTP/1.1" 200 -
203.0.113.2 - - [04/Nov/2026 21:59:34] "GET / HTTP/1.1" 200 -
root@router:~# nft list chain inet filter forward | grep -E "counter"
counter packets 4 bytes 288 comment "every other pair is dropped"- Lines 1–3: Staff: the internet and the server, both 200.
- Lines 5–8: Guest: the internet yes; the server and a staff PC no.
- Lines 11–13: Server: the internet yes; it can't start a connection to a staff PC.
- Lines 16–19: Every visitor arrives as 203.0.113.2: masquerade at work.
- Lines 21–22: The drops, counted by the forward chain.
root@staff:~# nc -zv -w 2 10.10.10.1 22
Connection to 10.10.10.1 22 port [tcp/ssh] succeeded!
root@guest:~# nc -zv -w 2 10.10.20.1 22; nc -zv -w 2 10.10.10.1 22; ping -c 1 -W 1 10.10.20.1 | tail -2
nc: connect to 10.10.20.1 port 22 (tcp) timed out: Operation now in progress
nc: connect to 10.10.10.1 port 22 (tcp) timed out: Operation now in progress
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.057/0.057/0.057/0.000 ms
root@router:~# nft list chain inet filter input | grep -E "SSH|everything"
iifname "staff" tcp dport 22 accept comment "SSH from staff only"
counter packets 4 bytes 240 comment "everything else to the router"- Lines 1–2: Staff may SSH to the router.
- Lines 4–6: A guest can't, on its own gateway address or on the router's staff address.
- Lines 7–8: Ping still works: useful when troubleshooting.
Check your understanding
You add a printers VLAN later. Without touching nftables.conf, what can the printers reach?
- Nothing through the router: no pair includes printers, so the forward chain drops it.
- Everything, because the router forwards by default.
- The internet, through masquerade.
- Whatever staff can reach.
Check your understanding
Why does a guest fail to SSH to 10.10.10.1, the router's address on the staff VLAN?
- The SSH rule matches the interface the packet arrived on (staff), not the address it was sent to.
- The router doesn't answer on 10.10.10.1 from other VLANs.
- The forward chain blocks it.
- sshd only listens on the staff address.