TTR Networks

Intermediate · nftables and iptables on Debian: host and router firewalls · section 5 of 10

A router firewall with NAT

✓ Lab-tested 21 October 2026 on Debian 13.7, nftables 1.1.3, iptables 1.8.11

A firewall for an office routes between two networks. That takes three things, and the lab adds them one at a time.

1. IP forwarding

A Linux machine only routes between interfaces when net.ipv4.ip_forward is 1, and on Debian it starts at 0. The office PC's pings go nowhere until a file in /etc/sysctl.d/ turns it on, now (sysctl --system) and at every boot.

2. NAT

With forwarding on, the pings do reach the internet host, but from 10.10.0.20, a private address nobody on the internet can reply to. Masquerade rewrites the source of packets leaving the WAN interface to the firewall's own public address, and conntrack rewrites the replies back. It goes in a separate table with a nat-type chain on the postrouting hook.

3. A forward policy: NAT is not a firewall

NAT makes the LAN hard to address from the internet, not unreachable. In the lab, a machine on the same WAN segment as the firewall (in real life, another customer of the same ISP, or the ISP itself) simply adds a route for 10.10.0.0/24 via the firewall. With forwarding on and no forward rules, it reaches the office web server directly.

The router ruleset fixes that with a forward chain, policy drop:

  • the office may go out to the internet;
  • replies come back because of ct state established,related;
  • from the internet, only connections that were port-forwarded are let in, matched with ct status dnat, so the forward rules and the port forwards can never disagree.

The port forward itself is a DNAT rule on the prerouting hook: connections to 203.0.113.10:8080 have their destination rewritten to the web server, 10.10.0.80:80, before the routing decision. The web server sees the visitor's real address.

define gives names to interfaces and addresses, and the admins set (next section) holds the addresses allowed to use SSH. The listing after loading shows the variables already replaced by their values.

The result: the office reaches the internet as 203.0.113.10, conntrack -L shows the translation it's tracking, the port forward works, and the neighbour's direct attempt is counted and dropped. After a reboot, nftables.service loads the same file again.

/etc/sysctl.d/99-forwarding.conf
# /etc/sysctl.d/99-forwarding.conf: let fw route between the office and the internet.
net.ipv4.ip_forward = 1
Forwarding off, then on (real output)
root@pc:~# ping -c 2 -W 1 203.0.113.50 | tail -2
2 packets transmitted, 0 received, 100% packet loss, time 1063ms


root@fw:~# sysctl net.ipv4.ip_forward
net.ipv4.ip_forward = 0

root@fw:~# cp 99-forwarding.conf /etc/sysctl.d/ && sysctl --system | grep ip_forward
net.ipv4.ip_forward = 1

root@pc:~# ping -c 2 -W 1 203.0.113.50 | tail -2
2 packets transmitted, 0 received, 100% packet loss, time 1029ms
  • Lines 1–2: The office PC can't reach the internet.
  • Lines 5–6: Debian's default: no routing.
  • Lines 8–9: On, now and after every reboot.
  • Lines 11–12: Still failing, but for a new reason…
…seen on the internet host (real output)
root@inet:~# timeout 5 tcpdump -lnni eth0 icmp
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on eth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
07:47:37.238174 IP 10.10.0.20 > 203.0.113.50: ICMP echo request, id 47951, seq 1, length 64
07:47:38.267155 IP 10.10.0.20 > 203.0.113.50: ICMP echo request, id 47951, seq 2, length 64

2 packets captured
2 packets received by filter
0 packets dropped by kernel
  • Lines 4–5: The pings arrive from 10.10.0.20, a private address. The internet host can't send replies there.
NAT is not a firewall (real output)
root@inet:~# ip route add 10.10.0.0/24 via 203.0.113.10 && curl -s -o /dev/null -w "%{http_code}\n" -m 3 http://10.10.0.80/
200

root@srv:~# tail -1 /tmp/web.log
203.0.113.50 - - [21/Oct/2026 07:47:48] "GET / HTTP/1.1" 200 -
  • Lines 1–2: A neighbour on the WAN segment routes the office's private range at the firewall, and reaches the web server.
  • Lines 4–5: The web server sees a visitor from the internet.
/etc/nftables.conf: the router
#!/usr/sbin/nft -f
# /etc/nftables.conf on fw: the office's router and firewall.
# Check, then load:  nft -c -f /etc/nftables.conf && systemctl reload nftables
flush ruleset

define WAN = "eth0"
define LAN = "eth1"
define WEBSERVER = 10.10.0.80

table inet filter {
  set admins {
    type ipv4_addr
    elements = { 10.10.0.20 }
  }

  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 $LAN ip saddr @admins tcp dport 22 accept comment "SSH from admin PCs"
    limit rate 6/minute burst 5 packets log prefix "nft input drop: "
    counter comment "dropped by the policy"
  }

  chain forward {
    type filter hook forward priority filter; policy drop;
    ct state established,related accept
    ct state invalid drop
    iifname $LAN oifname $WAN accept comment "the office to the internet"
    iifname $WAN oifname $LAN ct status dnat accept comment "port forwards, see the nat table"
    limit rate 6/minute burst 5 packets log prefix "nft forward drop: "
    counter comment "dropped by the policy"
  }

  chain output {
    type filter hook output priority filter; policy accept;
  }
}

table ip nat {
  chain prerouting {
    type nat hook prerouting priority dstnat;
    iifname $WAN tcp dport 8080 dnat to $WEBSERVER:80 comment "port forward to the office web server"
  }

  chain postrouting {
    type nat hook postrouting priority srcnat;
    oifname $WAN masquerade comment "the office shares the firewall's public address"
  }
}
  • Lines 6–8: Names for interfaces and addresses, used below.
  • Lines 11–14: A set of admin addresses; see Sets and maps.
  • Lines 23–24: Log a few dropped packets, and count them all.
  • Line 28: The forward policy: nothing is routed unless a rule allows it.
  • Line 31: The office may go out.
  • Line 32: From the internet, only what the nat table port-forwards.
  • Lines 42–46: DNAT on prerouting: 203.0.113.10:8080 becomes the web server, before routing.
  • Lines 48–51: Masquerade on postrouting: the office leaves as the firewall's address.
Loading it (real output)
root@fw:~# cp router.conf /etc/nftables.conf && nft -c -f /etc/nftables.conf && systemctl reload nftables && nft list ruleset
table inet filter {
	set admins {
		type ipv4_addr
		elements = { 10.10.0.20 }
	}

	chain input {
		type filter hook input priority filter; policy drop;
		ct state established,related accept
		ct state invalid drop
		iif "lo" accept
		icmp type { destination-unreachable, echo-request, time-exceeded } accept
		iifname "eth1" ip saddr @admins tcp dport 22 accept comment "SSH from admin PCs"
		limit rate 6/minute burst 5 packets log prefix "nft input drop: "
		counter packets 0 bytes 0 comment "dropped by the policy"
	}

	chain forward {
		type filter hook forward priority filter; policy drop;
		ct state established,related accept
		ct state invalid drop
		iifname "eth1" oifname "eth0" accept comment "the office to the internet"
		iifname "eth0" oifname "eth1" ct status dnat accept comment "port forwards, see the nat table"
		limit rate 6/minute burst 5 packets log prefix "nft forward drop: "
		counter packets 0 bytes 0 comment "dropped by the policy"
	}

	chain output {
		type filter hook output priority filter; policy accept;
	}
}
table ip nat {
	chain prerouting {
		type nat hook prerouting priority dstnat; policy accept;
		iifname "eth0" tcp dport 8080 dnat to 10.10.0.80:80 comment "port forward to the office web server"
	}

	chain postrouting {
		type nat hook postrouting priority srcnat; policy accept;
		oifname "eth0" masquerade comment "the office shares the firewall's public address"
	}
}
  • Line 1: Copy, check, reload: the service runs nft -f /etc/nftables.conf again.
  • Line 14: Variables are gone: the kernel only stores values.
  • Lines 33–43: The nat table, with policies filled in (nat chains always accept).
Testing the router (real output)
root@pc:~# curl -s -o /dev/null -w "%{http_code}\n" http://203.0.113.50/; ping -c 1 -W 1 203.0.113.50 | tail -2
200
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.033/0.033/0.033/0.000 ms

root@inet:~# tail -1 /tmp/web.log
203.0.113.10 - - [21/Oct/2026 07:48:10] "GET / HTTP/1.1" 200 -

root@fw:~# conntrack -L -p tcp --dport 80 2>/dev/null
tcp      6 119 TIME_WAIT src=10.10.0.20 dst=203.0.113.50 sport=39654 dport=80 src=203.0.113.50 dst=203.0.113.10 sport=80 dport=39654 [ASSURED] mark=0 use=1

root@inet:~# curl -s -o /dev/null -w "%{http_code}\n" -m 3 http://10.10.0.80/; curl -s -o /dev/null -w "%{http_code}\n" -m 3 http://203.0.113.10:8080/
000
200

root@srv:~# tail -1 /tmp/web.log
203.0.113.50 - - [21/Oct/2026 07:48:13] "GET / HTTP/1.1" 200 -

root@fw:~# nft list chain inet filter forward | grep -E "dnat|dropped"
		iifname "eth0" oifname "eth1" ct status dnat accept comment "port forwards, see the nat table"
		counter packets 3 bytes 180 comment "dropped by the policy"
  • Lines 1–4: The office reaches the internet.
  • Lines 6–7: …as the firewall's address.
  • Lines 9–10: conntrack's record: the original direction, and the reply direction with the rewritten address.
  • Lines 12–14: The neighbour's direct route is dropped now (000); the port forward works.
  • Lines 16–17: Through the port forward, the web server sees the visitor's real address.
  • Lines 19–21: Three dropped packets: the neighbour's SYN and two retries.
After a reboot (real output)
root@fw:~# systemctl is-active nftables; nft list tables
active
table inet filter
table ip nat
  • Lines 2–4: The service loaded the file again at boot.

Check your understanding

The office reaches the internet through masquerade. Why does the router still need a forward chain with policy drop?

  • Because NAT only hides the office; anyone who can route to the private range through the firewall reaches it.
  • Because masquerade only works with a forward chain.
  • To allow the replies back.
  • Because port forwards need it.

Check your understanding

Why does the forward chain accept ct status dnat instead of ip daddr 10.10.0.80 tcp dport 80?

  • It accepts exactly the connections the nat table forwarded, so the two can never disagree.
  • Because the forward chain can't see the rewritten address.
  • It's faster.
  • Because iptables needs it.