nftables: Firewall Rules Without the iptables Baggage
Tables, chains, sets and maps — a modern packet filter that doesn't fight you

Contents
iptables has been the default answer to “how do I write firewall rules on Linux” for so long that its warts feel normal. Four separate binaries for four protocol families (iptables, ip6tables, arptables, ebtables), each with its own syntax. Rule matching that’s a linear scan, so a ruleset with thousands of entries — a fail2ban instance with a long ban list, say — gets measurably slower per packet. No native way to group addresses or ports without either duplicating rules or reaching for the clunky ipset extension. nftables replaced all of that in the mainline kernel back in 3.13 (2014), and as of most current distributions it’s the default backend even when you type iptables out of habit — but writing rules in its native syntax rather than through the compatibility shim is where the actual improvements live.
Worth scoping this piece honestly: nftables is a host-level Linux firewall, the thing you run on the individual boxes doing work — a reverse proxy, a database server, a Kubernetes node. It isn’t a replacement for a dedicated router firewall like OPNsense, which sits on FreeBSD’s pf rather than Linux’s netfilter and operates at the network edge rather than on a single host. In a well-segmented homelab the two work together rather than competing: OPNsense enforces which VLANs can reach which other VLANs at the network boundary, and nftables enforces what an individual Linux host will actually accept once traffic reaches it, which matters even for traffic that’s already “allowed” by the router — defence in depth means a compromised host on an otherwise-trusted VLAN still can’t freely pivot, because its own nftables ruleset is a second, independent gate.
Why the switch is worth the relearning
The iptables-to-nftables move is a genuine model change, and the underlying differences matter once a ruleset grows past a handful of lines.
Native sets and maps replace what used to require ipset bolted on the side or, worse, hundreds of near-identical rules. A set of “these ten IPs are always blocked” collapses to one line and a single hash lookup, regardless of how many addresses the set eventually holds. Maps go further, letting a single rule branch on a value — port 80 goes to one verdict, port 443 to another — without separate rules for each.
One syntax covers IPv4, IPv6, ARP and bridge filtering, instead of four separate tools that don’t share state or vocabulary. A single ruleset file expresses a dual-stack firewall cleanly, which matters increasingly as native IPv6 stops being optional.
Atomic ruleset replacement is built in: nft -f ruleset.nft loads an entire new ruleset as one atomic transaction. With iptables, a naive iptables-restore briefly flushes and rebuilds, and depending on your defaults there’s a window — however small — where traffic isn’t governed by any rule at all. nftables just doesn’t have that failure mode.
And performance under scale is meaningfully better, because the kernel evaluates sets and maps with something closer to a hash lookup rather than a linear rule scan, which is the whole reason a long fail2ban ban list or a large geo-IP block list stops being a measurable cost.
The core concepts
nftables’ vocabulary is worth learning properly because it maps cleanly onto how you’ll actually think about a ruleset.
A table is a namespace holding chains, tied to an address family (ip, ip6, inet for both, arp, bridge, netdev). Using inet for a combined IPv4/IPv6 table is usually the right default now.
A chain is where rules live, attached to a netfilter hook (input, output, forward, prerouting, postrouting) with a priority and a policy (accept or drop). Unlike iptables, chains aren’t predefined — you create exactly the ones you need.
A set is a named, typed collection — addresses, ports, interfaces — that a rule can match against in one lookup, and which you can update independently of the rule that references it.
A map associates a set of keys with a set of values, letting one rule express what would otherwise be a small table of separate rules.
None of these four concepts is individually complicated, but the way they compose is what actually changes how you write rules day to day. With iptables, adding a new condition to an existing policy usually means writing a new rule, because the syntax doesn’t give you much else to reach for. With nftables, the same change is frequently a one-line update to a set’s elements, with the rule that references it untouched. That shift — from “editing rules” to “editing the data rules refer to” — is the real productivity difference, and it’s also why nftables rulesets tend to stay shorter and more legible even as the policy they express grows more elaborate over time. A ruleset with fifteen carefully-scoped rules referencing well-named sets is something you can read and trust six months later; a ruleset with four hundred near-identical iptables lines generated by some ad-hoc script rarely is.
A full ruleset
Here’s a representative home-router-style ruleset, saved as /etc/nftables.conf, combining a table, sets, and a verdict map:
| |
A few things worth calling out. The ct state established,related accept line near the top of every chain is doing most of the heavy lifting for a stateful firewall — it’s the reason you don’t need a return-traffic rule for every outbound connection. The blocked_addrs set uses flags interval, which lets it hold CIDR ranges rather than only individual addresses. And the final log prefix ... drop line is deliberate: an explicit logged drop at the end of the chain, rather than relying only on the chain’s policy, makes tailing the kernel log for dropped traffic far less ambiguous when you’re debugging.
Loading it is one command, and it’s atomic — the whole ruleset applies or none of it does:
| |
Working with sets at runtime
The genuine day-to-day win over iptables shows up here — updating a block list doesn’t mean rewriting a rule, just the set’s contents:
| |
This is exactly the primitive something like fail2ban or crowdsec wants underneath it — see the fail2ban and CrowdSec piece for the application layer that typically drives these set updates automatically as bans are issued and expire.
Counters and rate limiting
nftables makes per-rule counters and rate limiting native syntax rather than a bolted-on module, which is a genuine quality-of-life improvement for anything facing the internet. Adding counter to a rule tracks packets and bytes matched, visible directly in nft list ruleset -a, which turns “is this rule even being hit” from a guessing game into a one-command check:
| |
That single line rate-limits new SSH connection attempts to five a minute and counts everything that matches, combining what would be a hashlimit module invocation and a separate -m comment counter trick in iptables into one readable statement. For a home-router SSH listener facing the internet, a rule like this quietly blunts the constant background noise of credential-stuffing bots without needing fail2ban to do any work at all — though the two are complementary rather than competing, since fail2ban can still ban an address outright after repeated attempts slip past the rate limit.
Chain priorities and hook order
The priority 0 seen throughout the example ruleset isn’t cosmetic — it determines the order chains attached to the same hook are evaluated in, and it matters the moment more than one table wants a say over the same traffic. Lower numbers run first. nftables defines named priority constants (filter, nat, mangle, raw, security) that map to sensible defaults, but explicit integers work identically and are what you’ll see in most example rulesets, including this one.
This becomes concrete once NAT and filtering rules live in the same chain hook but need to run in a specific relative order — DNAT has to happen before the filter chain evaluates the post-translation destination, or a filter rule written against the original destination address silently never matches. Keeping NAT (priority -100 by convention) and filter (priority 0) as separate chains with different priorities, rather than cramming both concerns into one chain, avoids an entire category of “the rule looks right but never matches” debugging session.
Migrating from an existing iptables setup
If there’s an existing iptables ruleset worth preserving rather than rewriting from scratch, iptables-translate (and ip6tables-translate) converts individual rules line by line:
| |
It’s a reasonable starting point but not a substitute for actually reading the output — mechanically translated rulesets tend to preserve iptables’ rule-per-line structure rather than taking advantage of sets and maps, so a translated ruleset works but doesn’t get you any of the performance or maintainability benefits until you refactor it by hand.
The refactor itself is usually mechanical once you see the pattern. Grep the translated output for repeated rules that differ only in a single address or port — anywhere you see the same accept/drop verdict attached to a run of near-identical match conditions, that’s a set waiting to be extracted. Pull the varying values into a named set, replace the run of rules with one rule referencing it, and move on to the next cluster. A ruleset that started as two hundred translated lines commonly collapses to twenty or thirty real rules plus a handful of sets, and the resulting file is something you can actually hold in your head, which matters enormously the next time you’re debugging at 1am and need to reason about why a specific packet was dropped. I’ve done this refactor on inherited rulesets from other people’s routers and it routinely surfaces rules that were dead weight — duplicated, shadowed by an earlier rule, or referencing an address that hasn’t existed on the network in years — cruft that’s invisible in iptables’ linear format but obvious once related rules are forced to live together in one set.
It’s also worth running the migration in stages rather than as a single cutover on a box you can’t afford to lose remote access to. Load the new nftables ruleset alongside the still-running iptables rules first — using a different table name so they don’t collide — verify the logic with nft list ruleset and a handful of test connections, and only then remove the old iptables rules and switch the loaded nftables table over to the real names. Doing the whole thing in one shot on a remote box, with no console access as a fallback, is how people lock themselves out of a server over a rule that looked correct on paper but didn’t actually match what they intended.
Troubleshooting
Rules load with nft -f but nothing is actually being filtered. Check that the nftables service is enabled and that nothing else — NetworkManager’s firewall integration, or a leftover iptables-legacy ruleset — is also active and taking precedence. Running iptables -L on a system that’s supposedly all-nftables and seeing a populated legacy ruleset is the classic sign of two firewall stacks fighting each other; update-alternatives --config iptables on Debian-family systems shows and controls which backend is actually live.
A rule referencing a set fails to load with “Element does not match type expected.” The set’s declared type has to match exactly what elements are added to it — mixing bare addresses into a set typed for ipv4_addr . inet_service (an address-port pair) is the usual culprit, and the error message, unhelpfully, doesn’t say which element.
Established connections keep getting dropped after a ruleset reload. nft -f with flush ruleset at the top clears conntrack-independent state but can interact badly with connection tracking if the reload happens mid-connection on a busy router — for a router forwarding live traffic, consider nft -f against a staged table and an atomic rename/swap rather than a full flush-and-reload during peak use.
Logged drops show in dmesg but you can’t tell which rule triggered them. Always give log statements a distinct prefix per chain (as in the example above) — an unlabelled log drop from three different chains looks identical in the kernel ring buffer, and you’ll waste time correlating timestamps instead of reading the prefix.
Performance doesn’t improve after migrating a large iptables ruleset. If the migration was rule-by-rule via iptables-translate, a hundred sequential ip saddr X accept rules translate to a hundred sequential nftables rules too — the performance benefit only appears once those addresses are consolidated into a single set with one lookup.
Is it worth it
For a handful of rules on a single box, the difference between iptables and nftables syntax barely matters — either gets the job done in ten minutes. The case for learning nftables properly shows up once a ruleset has real structure: a home router forwarding for several VLANs, a fail2ban instance with a block list that grows into the hundreds, or any setup where you want IPv4 and IPv6 handled by the same rules instead of two parallel, easily-diverging rulesets. It’s also simply where the kernel and distributions are heading — most current distributions run nftables underneath even when iptables commands still work, via the compatibility layer. Learning the native syntax now means the ruleset you write is the one actually running, not a shimmed translation of a decade-old design.




