Stop Writing Firewall Rules Twice: Practical nftables on Linux
A dual-stack host running iptables has two firewalls that are supposed to be identical. One lives in iptables, one in ip6tables, each with its own save file, its own restore command, and its own chance to drift. Add a po
A dual-stack host running iptables has two firewalls that are supposed to be identical. One lives in iptables, one in ip6tables, each with its own save file, its own restore command, and its own chance to drift. Add a port to one and forget the other, and the service is reachable over IPv4 while IPv6 quietly rejects it.
nftables was designed to remove that duplication. One tool, one ruleset, one transaction, both protocols. But the syntax is different enough that most people copy a snippet, get it working, and never learn why it works â until the day a rule silently stops matching.
This is the mental model, a complete server ruleset, and the failure modes worth knowing before you deploy it.
One ruleset, one transaction
The most important behavioural difference is not syntax. It is how changes are applied.
Each iptables invocation is its own kernel operation. A script that adds twenty rules is twenty separate moments in time, and a mistake halfway through leaves you with half a policy. iptables-restore improves this by applying a table in one go, but you still need separate IPv4 and IPv6 files.
nft -f file parses and validates the entire file, then commits it as a single transaction. If one line is wrong, nothing is applied:
sudo nft -c -f /etc/nftables.conf # parse and validate only, change nothing
sudo nft -f /etc/nftables.conf # apply atomically
That -c flag is the cheapest insurance in this article. Run it before every apply, especially over SSH.
The mental model: tables, chains, rules
nftables has three levels, and confusing them is why copied rulesets feel arbitrary.
A table belongs to an address family and holds everything else. A chain is attached to a hook in the packet path (or called from another chain). A rule is a list of expressions followed by statements: match something, then decide.
The families you will actually use:
| Family | Handles |
|---|---|
ip |
IPv4 only |
ip6 |
IPv6 only |
inet |
IPv4 and IPv6 together |
bridge |
Ethernet frames crossing a bridge |
netdev |
Per-interface ingress/egress, before layer 3 |
arp |
ARP |
For a normal server, inet is the whole point. You write the rule once and it is evaluated for both protocols; expressions that only make sense for one family simply do not match the other.
Within ip, ip6, and inet, the five hooks are prerouting, input, forward, output, and postrouting. A base chain declares which hook it belongs to and at what priority. Lower numbers run first, and the standard names are worth memorising because they tell you where you are relative to NAT:
| Priority name | Value | Hook |
|---|---|---|
raw |
-300 | before conntrack |
mangle |
-150 | all |
dstnat |
-100 | prerouting |
filter |
0 | all |
security |
50 | all |
srcnat |
100 | postrouting |
A base chain also has a type. filter is the default choice. nat chains are special: only the first packet of a connection traverses them, because their job is to create the conntrack entry that later packets reuse. route chains re-run the route lookup when a rule changed the destination.
And a base chain has a policy: accept or drop. The policy only applies to packets that reach the end of the chain without a verdict.
A complete dual-stack server ruleset
Here is a full /etc/nftables.conf for a host exposing SSH and a web server. It is deliberately boring, because boring firewalls are the ones you can reason about at 3 a.m.
#!/usr/sbin/nft -f
# /etc/nftables.conf â one ruleset for IPv4 and IPv6
flush ruleset
table inet filter {
set admin_nets {
type ipv4_addr
flags interval
elements = { 203.0.113.0/24, 198.51.100.10 }
}
chain input {
type filter hook input priority filter; policy drop;
# Drop packets conntrack already considers broken
ct state invalid counter drop
# Replies to connections we opened, plus related ICMP errors
ct state established,related accept
# Never filter loopback: local resolvers, databases, and sockets rely on it
iifname "lo" accept
# Trusted management networks get full access to this host
ip saddr @admin_nets accept
# IPv6 neighbour discovery. Drop this and IPv6 fails in confusing ways.
icmpv6 type { nd-neighbor-solicit, nd-router-advert, nd-neighbor-advert } accept
# Rate-limited ping, still useful for diagnostics
ip protocol icmp icmp type echo-request limit rate 5/second accept
# New SSH connections, capped per source burst
tcp dport 22 ct state new limit rate 10/minute burst 5 packets accept
tcp dport { 80, 443 } accept
# Log what is about to be dropped, but do not let logging flood the disk
limit rate 5/minute log prefix "nft input drop: " level info
counter drop
}
chain forward {
# This host is not a router
type filter hook forward priority filter; policy drop;
}
}
A few details that matter more than they look:
-
ct state established,related acceptmust come before the port rules. Without it, reply traffic from your own outbound connections is dropped by the policy. - The ICMPv6 rule is not optional. Neighbour solicitation and router advertisements are how IPv6 hosts find each other and their gateway. Filter them and the network appears to work, then stalls.
-
iifnamematches the interface name.iifmatches the interface index, which is unstable for interfaces that are created and destroyed (veth pairs, tunnels). If a rule works at boot and stops working after a container restart, this is usually why. - The final
logrule has no verdict, so evaluation continues tocounter drop. Thelimitin front means only the first five packets per minute are logged; the rest are still dropped, just not printed.
No output chain is defined. With no chain on that hook, outbound traffic is accepted â which is what you want on a server unless you are building an egress filter.
Sets: stop writing one rule per address
The admin_nets block above is a named set. Referencing it with @admin_nets turns a potential list of forty rules into one lookup against a data structure.
The practical payoff is runtime updates. You do not have to rewrite the ruleset to add an address:
sudo nft add element inet filter admin_nets { 192.0.2.0/24 }
sudo nft delete element inet filter admin_nets { 198.51.100.10 }
sudo nft list set inet filter admin_nets
flags interval is what allows CIDR ranges in the set and lets the kernel merge overlapping intervals. Without it, the set only accepts single addresses.
Sets also support timeouts, which is how you build temporary bans without a cron job:
set ssh_guard {
type ipv4_addr
flags timeout
timeout 1h
}
Elements added to that set expire on their own after an hour. Firewall tooling such as fail2ban and CrowdSec can use the same mechanism when it is configured for the nftables backend.
NAT is a chain type, not a separate tool
Source NAT and port forwarding live in nat chains. A minimal IPv4 router or container host looks like this:
table ip nat {
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
iifname "eth0" tcp dport 8080 dnat to 10.0.0.10:80
}
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
oifname "eth0" masquerade
}
}
Two things surprise people here. First, masquerade is dynamic source NAT: it uses the address currently on the outgoing interface, so it survives DHCP changes. snat to pins a specific address. Second, because NAT chains only see the first packet of a connection, a counter on a NAT rule counts connections, not packets. Do not use it to measure bandwidth.
Making it survive a reboot
A ruleset applied with nft -f is gone after a reboot unless something loads it again. On Debian and Ubuntu, the nftables package ships /etc/nftables.conf and a nftables.service that is not enabled by default. Installing the package does not activate your file.
sudo systemctl enable --now nftables
sudo systemctl reload nftables # re-reads /etc/nftables.conf
systemctl status nftables --no-pager
The service is Type=oneshot with RemainAfterExit=yes: it runs once at boot and stays "active". A syntax error means the unit fails and the host boots with no rules at all, which is fail-open. That is the strongest argument for running nft -c -f in a pre-deploy check, and for keeping a tested copy of the file in version control.
If you use cloud-init, configuration management, or a container runtime that manages netfilter, pick one owner. Two systems writing the same tables will overwrite each other, and the symptom is rules that exist until the next reboot.
Debugging what the kernel actually has
Reading the live ruleset is the first step in every firewall bug:
sudo nft list ruleset # everything, all families
sudo nft -a list ruleset # include handles for precise edits
sudo nft -s list chain inet filter input # omit counters and other state
sudo nft -j list ruleset | jq '.nftables[].table' # machine-readable
Counters only exist on rules that explicitly contain the counter statement. If a rule shows no packet count, it is not being evaluated â usually because an earlier accept or drop already matched, or because the chain is attached to a different hook than you think.
For the harder cases, nftables can trace a packet through the ruleset:
sudo nft add rule inet filter input meta nftrace set 1
sudo nft monitor trace
# ... reproduce the traffic in another terminal, then Ctrl-C ...
sudo nft delete rule inet filter input handle <handle>
The trace output shows every rule the packet visited and the verdict it received. Remove the tracing rule afterwards; it is expensive on a busy interface.
nft --debug=netlink is the other useful lever when a rule is rejected or translated into something unexpected: it prints the netlink messages nft is sending to the kernel, which is the ground truth.
The traps that actually bite
Flushing the wrong ruleset. flush ruleset removes every table in every family, not just yours. On a host running Docker, libvirt, or Kubernetes, that deletes their NAT and filter chains too. Container networking breaks until the runtime re-creates them. If you need to coexist, scope the flush to your own table (delete table inet filter) instead of wiping the world.
Mixing nftables with ufw or firewalld. Both write their own tables. Enabling nftables.service on top of them produces rules that appear and disappear depending on boot order. Choose one front end.
The iptables compatibility layer is still nftables. On current distributions, iptables is usually the nft-backed variant, so its rules appear in nft list ruleset as compat tables. This is fine, but it means nft list ruleset can look duplicated or unfamiliar. update-alternatives --config iptables shows which backend is active; do not switch backends on a live firewall host.
Locking yourself out. Applying a policy drop ruleset over SSH with no allow rule ends the session immediately, and the fix requires console access. Validate first, then apply with an automatic rollback armed:
# Snapshot the working ruleset, including the flush, as one atomic file
{ echo 'flush ruleset'; sudo nft list ruleset; } | sudo tee /root/nft-backup.nft >/dev/null
sudo nft -c -f /etc/nftables.conf
# If you cannot cancel this within 10 minutes, the old ruleset comes back
sudo systemd-run --on-active=10min --unit=nft-rollback \
/usr/sbin/nft -f /root/nft-backup.nft
sudo nft -f /etc/nftables.conf
# SSH still working? Cancel the rollback:
sudo systemctl stop nft-rollback.timer
The backup file starts with flush ruleset, so restoring it is a single atomic transaction rather than a merge into a half-broken policy.
A short migration path
You do not have to rewrite everything at once. Translate one table at a time, compare the live ruleset against the old one between changes, and keep the old ruleset saved. The nftables wiki's server example is a good reference for the canonical input chain, and nft list ruleset output is valid input to nft -f, which makes backups and diffs straightforward.
The end state is worth it: one file, one command, one transaction, and no second copy of your policy for the other address family.
Checklist before you deploy
-
nft -c -f /etc/nftables.confpasses. - Loopback is accepted before the policy drop.
-
ct state established,relatedis accepted. - ICMPv6 neighbour discovery is accepted if IPv6 is enabled.
- The ruleset is loaded by a service or config management at boot, not just typed into a shell.
- A rollback path exists and has been tested.
- Nothing else (Docker, ufw, firewalld) owns the same tables.
Sources
- nft(8), the nftables administration tool manual: https://manpages.debian.org/trixie/nftables/nft.8.en.html
- nftables wiki, "Simple ruleset for a server": https://wiki.nftables.org/wiki-nftables/index.php/Simple_ruleset_for_a_server
- nftables project documentation and release notes: https://www.netfilter.org/projects/nftables/
- Debian package
nftables1.1.3-1 (trixie), including/etc/nftables.confandnftables.service
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes â full credit and traffic to the original publisher.