nftables has replaced iptables as the default network filtering framework on most modern Linux distributions for several years, but this migration rarely finishes in practice: a compatibility layer keeps old iptables rules running without ever forcing their native rewrite, a pragmatic choice with its own traps.
What nftables actually changes
iptables handled IPv4 filtering separately from ip6tables (IPv6), arptables (ARP), and ebtables (Ethernet bridging), four distinct tools each with their own syntax. nftables unifies these four domains into a single framework, with a single rule language, eliminating the logic duplication that used to exist between iptables and ip6tables for conceptually identical rules.
# nftables: a single table covers IPv4 and IPv6
# together, where iptables required two separate commands
nft add rule inet filter input ip saddr 10.0.0.0/8 accept
nft add rule inet filter input ip6 saddr fd00::/8 accept
The quiet advantage: atomic ruleset replacement
The comparison usually quoted pits nftables against a bash script chaining several iptables commands: for the fractions of a second the script takes to run, the firewall sits in a partially configured state. That is exactly how the nftables wiki frames it, and nft -f closes that window — the whole file is read, the config object built in memory alongside the existing one, then swapped in a single atomic operation.
It is worth being precise about what that comparison holds against. The same wiki states the equivalence plainly: prepending a flush directive to the file gives you “atomic ruleset replacement equivalent to what iptables-restore provides”. Atomicity is therefore not what nftables invents against a well-used iptables; it is what it makes reachable without a script. What it genuinely adds is scope: the iptables-save/iptables-restore format is structured per table — one *filter … COMMIT block per table — and both flushing and restoring are expressed table by table (without --noflush, both commands flush “all previous contents of the respective table”; -T restores only a named table). A flush ruleset at the top of an nft file covers every table and every family in the same transaction.
And there is a first-order trap inside that file:
# With no flush directive at the top of the file, nft -f
# ADDS to the existing ruleset. Atomically, but it adds:
# reloading three times gives you three copies of the rules.
head -1 new-ruleset.nft
# flush ruleset <- the line that turns a load into a replacement
nft -f new-ruleset.nft
flush table filter is enough for one table, but it does not flush the sets that table contains: flush ruleset is what actually starts from scratch.
The compatibility layer: iptables-nft
Most modern distributions run iptables-nft, a compatibility layer translating classic iptables commands to the nftables backend, without ever requiring existing scripts to be rewritten. This translation works well for most common use cases, but creates a real trap: native nftables rules and iptables rules translated through this layer can coexist in separate tables, invisible to each other at a glance.
# Checks whether iptables runs in translation
# mode to nftables, or native legacy mode
iptables --version
# iptables v1.8.7 (nf_tables): translation mode
# iptables v1.8.7 (legacy) : historical native mode
firewalld, ufw, Docker: the frontend won’t tell you which backend writes
On a modern server, almost nobody writes rules by hand: a frontend does it. And the three most common ones answer the question “who writes into which subsystem” differently.
firewalld answers it explicitly. Its configuration carries a FirewallBackend setting whose possible values are nftables — the default — or iptables, the latter documented as deprecated and slated for removal. One exception is spelled out in the manual: direct and passthrough rules always use the traditional iptables, ip6tables and ebtables backends. A firewalld configuration you believe to be fully native therefore keeps a foot in x_tables the moment somebody added a direct rule. A second surprising detail: NftablesTableOwner defaults to yes, which gives firewalld exclusive ownership of the generated ruleset and prevents any other entity from modifying it. When a hand-written addition to that table won’t stick, it isn’t a bug, it’s the setting.
ufw answers nothing, because the question doesn’t arise for it. Its manual describes it as “in part a front-end for iptables-restore” and says it loads its rules files into the kernel using iptables-restore and ip6tables-restore. ufw therefore never speaks nftables: it speaks iptables. Wherever the iptables command in the PATH is iptables-nft, its rules land in nf_tables all the same, translated — the mechanism from the previous section, applied without anyone deciding it.
Docker has its own switch: the firewall-backend daemon option picks between iptables and nftables, with iptables as the default, and the documentation notes that for bridge networks the two have the same functionality. The warning attached to that setting is worth reading before flipping it: nftables support, introduced in Docker 29.0.0, is documented as experimental, “configuration options, behavior and implementation may all change in future releases”. It cannot be enabled while the daemon runs in Swarm mode, the overlay network rules not having been migrated yet. And in that mode Docker does not enable IP forwarding itself and will not create a default drop policy — two things it does do with the iptables backend, and whose absence doesn’t show until you look for it. Two interactions are documented there and are worth knowing before diagnosing anything. With firewalld enabled, Docker creates a docker zone with target ACCEPT plus a docker-forwarding policy allowing forwarding from any zone into it. With ufw, the Docker documentation is blunt: the two “use firewall rules in ways that make them incompatible with each other”. Traffic to a published port is routed in the nat table, so it is diverted before it reaches the INPUT and OUTPUT chains ufw relies on, effectively ignoring its configuration.
The diagnostic trap: two views that don’t show everything
An administrator checking only iptables -L can miss native nftables rules added by another tool (Docker, some modern firewall software manage nftables directly without going through the compatibility layer). Conversely, nft list ruleset shows native nftables rules, but doesn’t always intuitively represent rules injected via the iptables-nft translation layer, depending on which table they land in.
That readability gap hides a worse one, of coverage: there is no single command that shows everything. iptables-legacy writes to the x_tables kernel subsystem, while nft and iptables-nft write to nf_tables. Two disjoint stores. nft list ruleset is therefore blind to legacy rules — precisely the coexistence case that causes trouble. The nftables wiki explicitly warns against using both tool families at once, which means driving both kernel subsystems at the same time, with unpredictable results. And the blind spot is not cosmetic: the same wiki spells out, on its troubleshooting page, what happens when both coexist on a host — the most restrictive verdict wins. A blocking rule that appears in no nft list ruleset output still blocks the traffic.
# The reliable view is a pair, not one command:
# the nf_tables side (native plus iptables-nft translations)
nft list ruleset
# the x_tables side, invisible to the command above
iptables-legacy-save
# On Debian/Ubuntu, find out which backend the
# "iptables" command in your PATH actually drives
update-alternatives --display iptables
The diagnostic habit is therefore not to swap iptables -L for nft list ruleset, but to look at both subsystems before concluding a rule does not exist. That same need to see the real source of truth rather than a partial view echoes the logic behind Cilium and eBPF, which sidesteps this double-bookkeeping risk entirely by replacing iptables with a single dataplane on the Kubernetes side.
Finishing the migration: translate, then rewrite
If the migration never finishes, it is first of all because nothing forces it. The Debian 10 release notes are explicit: since iptables 1.8.2 the package ships both iptables-nft and iptables-legacy, the nftables variant is the default, the legacy variant stays installed, and update-alternatives lets you switch between them. The default changed; the old road stayed open.
The tools to actually finish ship in the iptables tarball, alongside the commands they replace. iptables-translate converts a single command, which is the right way to learn the syntax. iptables-restore-translate converts an entire ruleset.
# One rule, to learn the syntax
iptables-translate -A INPUT -p tcp --dport 22 -m conntrack --ctstate NEW -j ACCEPT
# nft add rule ip filter INPUT tcp dport 22 ct state new counter accept
# The whole ruleset, in two steps
iptables-save > save.txt
iptables-restore-translate -f save.txt > ruleset.nft
nft -f ruleset.nft
The translation is mechanical, and that is its limit: it reproduces the same chains, under the same names, with the same linear structure. It gets you off x_tables and buys you nothing else. The nftables wiki presents it as a step, not an arrival: after migrating, you are “encouraged to implement new nftables mechanisms such as sets, maps, verdict maps, concatenations and more”.
That is where the performance argument sits, and it deserves to be stated for what it is. An iptables chain is inspected rule by rule; the wiki describes verdict maps precisely as a way to “reduce the number of linear list inspections needed to classify your packets”. An nftables set, by contrast, is looked up rather than walked: its elements are internally represented using performance data structures, hashtables and red-black trees. The Debian 10 notes summarise the same point from the project side — faster packet classification through enhanced generic set and map infrastructures.
Concretely, four rules enumerating four ICMPv6 types collapse into one rule and a set:
# What the translation produces: the original linear structure,
# four rules inspected one after another.
# What the rewrite produces: a set, looked up in one go.
nft add rule ip6 filter input icmpv6 type \
{ nd-neighbor-solicit, echo-request, nd-router-advert, nd-neighbor-advert } accept
The gain therefore rides on the length of the list being walked. That is an argument for a long ruleset, much less so for a handful of rules — and that is exactly why the migration stays unfinished on so many hosts. The compatibility layer works, and the one step that would pay for itself is the one nothing demands.
Takeaway
nftables unifies IPv4, IPv6, ARP, and Ethernet bridge filtering into a single framework, and what it brings to reloading is a matter of scope rather than novelty: a transaction covering the whole ruleset, every table and every family at once, where the iptables-restore format is expressed table by table. The iptables-nft compatibility layer, active by default on most modern distributions, runs old scripts without ever forcing their rewrite, a pragmatic choice that lets native and translated rules coexist, sometimes invisible to each other depending on the diagnostic tool used. No single command gives you the kernel’s actual state: nft list ruleset covers nf_tables, iptables-legacy-save covers x_tables, and you need both before claiming a rule does not exist — a diagnostic habit that stays relevant even on a largely containerized infrastructure where the host firewall still exists underneath the abstraction layers. And that “who writes where” question almost never gets asked of iptables or nft directly: it gets asked of firewalld, of ufw and of Docker, which each answer it differently, and whose documentation is the only place the answer is written down.