iptables on Debian
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
DROPblocks the office PC's web traffic, even though the nftables forward chain accepted it. - An iptables
ACCEPTfor 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.
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.
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.
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.
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.
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.
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.