TTR Networks

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

iptables on Debian

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

Most firewall advice on the internet, and a lot of software, still uses iptables. On Debian 13, iptables is iptables-nft: the old commands and syntax, writing nftables rules into tables named after the old ones (ip filter, ip nat). The original implementation is still installable as iptables-legacy, but don't mix the two.

Two firewalls on one hook

An iptables rule is real nftables underneath, so it shows up in nft list ruleset, in its own table and its own base chain on the same hook. And from One hook, several chains: every chain on the hook must accept a packet, and a drop in any of them wins.

The lab shows both directions:

  • An iptables DROP blocks the office PC's web traffic, even though the nftables forward chain accepted it.
  • An iptables ACCEPT for the neighbour does not let it in: the iptables chain accepts it, and the nftables chain still drops it.

That's the usual reason for "I allowed it and it's still blocked": another tool's chain on the same hook. List the whole ruleset, not just your table.

A related trap: the lab's own table ip nat has the same name as iptables' NAT table. iptables -t nat -L can't display chains that nft created, so it prints a warning and an empty listing, which looks like no NAT at all. Look with nft.

Translating old rules

iptables-translate turns an iptables command into the equivalent nft rule, and iptables-restore-translate does a whole saved ruleset. Use them to learn the syntax and to migrate, but tidy the result: translated rules keep iptables' table names and add a counter everywhere.

When other software owns rules

Docker, fail2ban, libvirt and Kubernetes add their own tables, most of them through iptables-nft. Debian's default file starts with flush ruleset, which removes everything, theirs included: on the lab machine, the first load deleted Docker's DNS rules, and a reload removes anything added by commands since, iptables rules included. On a machine where other software manages rules, don't flush the whole ruleset; delete and recreate only your own tables (destroy table inet filter), and restart that software after changing yours if it still breaks.

An iptables rule next to the nftables ruleset (real output)
root@fw:~# iptables -V; update-alternatives --query iptables | grep -E "^(Value|Alternative):"
iptables v1.8.11 (nf_tables)
Value: /usr/sbin/iptables-nft
Alternative: /usr/sbin/iptables-legacy
Alternative: /usr/sbin/iptables-nft

root@fw:~# iptables -A FORWARD -s 10.10.0.20 -p tcp --dport 80 -j DROP && nft list table ip filter
table ip filter {
	chain FORWARD {
		type filter hook forward priority filter; policy accept;
		ip saddr 10.10.0.20 tcp dport 80 counter packets 0 bytes 0 drop
	}
}

root@pc:~# curl -s -o /dev/null -w "%{http_code}\n" -m 2 http://203.0.113.50/
000

root@fw:~# nft list chain inet filter forward | grep "office to"; iptables -L FORWARD -v -n
		iifname "eth1" oifname "eth0" accept comment "the office to the internet"
Chain FORWARD (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination         
    2   120 DROP       tcp  --  *      *       10.10.0.20           0.0.0.0/0            tcp dpt:80
  • Lines 2–5: iptables is the nf_tables version; the legacy one is the other alternative.
  • Lines 7–13: The iptables rule is a real nftables table and base chain on the forward hook.
  • Lines 15–16: The office PC's web request fails…
  • Lines 19–22: …although the nftables chain accepts it. The iptables chain dropped it: 2 packets.
An iptables ACCEPT can't override an nftables drop (real output)
root@fw:~# iptables -I FORWARD -s 203.0.113.50 -d 10.10.0.80 -j ACCEPT && iptables -L FORWARD -n --line-numbers
Chain FORWARD (policy ACCEPT)
num  target     prot opt source               destination         
1    ACCEPT     all  --  203.0.113.50         10.10.0.80          
2    DROP       tcp  --  10.10.0.20           0.0.0.0/0            tcp dpt:80

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

root@fw:~# iptables -L FORWARD -v -n | grep ACCEPT; journalctl -k --no-pager -o cat | grep "nft forward drop" | tail -1
Chain FORWARD (policy ACCEPT 0 packets, 0 bytes)
    2   120 ACCEPT     all  --  *      *       203.0.113.50         10.10.0.80          
nft forward drop: IN=eth0 OUT=eth1 MAC=6a:11:8d:11:a4:35:ae:08:ff:c3:3e:c8:08:00 SRC=203.0.113.50 DST=10.10.0.80 LEN=60 TOS=0x00 PREC=0x00 TTL=63 ID=16211 DF PROTO=TCP SPT=55364 DPT=80 WINDOW=64240 RES=0x00 SYN URGP=0 
  • Lines 1–5: Allow the neighbour, at the top of iptables' FORWARD.
  • Lines 7–8: Still blocked.
  • Lines 11–13: iptables accepted both packets; the nftables forward chain still dropped them.
What iptables makes of an nftables table (real output)
root@fw:~# iptables -t nat -L -n
# Table `nat' contains incompatible base-chains, use 'nft' tool to list them.
Chain PREROUTING (policy ACCEPT)
target     prot opt source               destination         

Chain INPUT (policy ACCEPT)
target     prot opt source               destination         

Chain OUTPUT (policy ACCEPT)
target     prot opt source               destination         

Chain POSTROUTING (policy ACCEPT)
target     prot opt source               destination         
  • Line 2: The table exists, but iptables can't read it…
  • Lines 3–13: …so it looks empty. Use nft list table ip nat.
Translating iptables rules (real output)
root@fw:~# iptables-translate -A INPUT -p tcp --dport 22 -s 10.10.0.0/24 -m conntrack --ctstate NEW -j ACCEPT
nft 'add rule ip filter INPUT ip saddr 10.10.0.0/24 tcp dport 22 ct state new counter accept'

root@fw:~# iptables-translate -t nat -A PREROUTING -i eth0 -p tcp --dport 8080 -j DNAT --to-destination 10.10.0.80:80
nft 'add rule ip nat PREROUTING iifname "eth0" tcp dport 8080 counter dnat to 10.10.0.80:80'

root@fw:~# iptables-translate -A INPUT -p tcp -m multiport --dports 80,443 -m limit --limit 10/min -j LOG --log-prefix "web: "
nft 'add rule ip filter INPUT ip protocol tcp tcp dport { 80, 443 } limit rate 10/minute burst 5 packets counter log prefix "web: "'

root@fw:~# iptables-save > /root/old.rules && iptables-restore-translate -f /root/old.rules
# Translated by iptables-restore-translate v1.8.11 on Wed Oct 21 07:50:49 2026
add table ip filter
add chain ip filter INPUT { type filter hook input priority 0; policy accept; }
add chain ip filter FORWARD { type filter hook forward priority 0; policy accept; }
add chain ip filter OUTPUT { type filter hook output priority 0; policy accept; }
add rule ip filter FORWARD ip saddr 203.0.113.50 ip daddr 10.10.0.80 counter accept
add rule ip filter FORWARD ip saddr 10.10.0.20 tcp dport 80 counter drop
add table ip nat
add chain ip nat PREROUTING { type nat hook prerouting priority -100; policy accept; }
add chain ip nat INPUT { type nat hook input priority 100; policy accept; }
add chain ip nat OUTPUT { type nat hook output priority -100; policy accept; }
add chain ip nat POSTROUTING { type nat hook postrouting priority 100; policy accept; }
# Completed on Wed Oct 21 07:50:49 2026
  • Lines 1–2: A typical guide's SSH rule, as nftables.
  • Lines 4–5: A port forward.
  • Lines 7–8: multiport becomes an anonymous set; LOG and limit translate too.
  • Lines 10–23: A whole saved ruleset. Only rules iptables itself made are translated.
flush ruleset removed Docker's table (real output)
root@fw:~# nft list tables
table inet filter
  • Lines 1–2: After the first systemctl enable --now nftables: only our table is left. Docker's DNS rules from Tables, chains and hooks are gone.
A reload puts back exactly the file (real output)
root@fw:~# nft list tables; systemctl reload nftables && echo "--- after the reload:" && nft list tables
table inet filter
table ip nat
table ip filter
--- after the reload:
table inet filter
table ip nat

root@pc:~# curl -s -o /dev/null -w "%{http_code}\n" -m 2 http://203.0.113.50/
200

root@fw:~# nft list set inet filter admins | grep elements; nft list table ip nat | grep dnat
		elements = { 10.10.0.20 }
		iifname "eth0" tcp dport 8080 dnat to 10.10.0.80:80 comment "port forward to the office web server"
  • Lines 1–4: Before: the iptables table is still there.
  • Lines 5–7: After: only what /etc/nftables.conf creates.
  • Lines 9–10: The office PC works again.
  • Lines 12–14: Live changes are gone: the map is back to the single forward from the file.

Check your understanding

A colleague added iptables -A FORWARD … -j ACCEPT to let a partner in, but it's still blocked. Why?

  • The nftables forward chain on the same hook still drops it; an accept in one chain doesn't skip the others.
  • iptables rules don't work on Debian 13.
  • The rule must be inserted with -I instead of -A.
  • iptables-legacy must be selected first.