iptables vs nftables: A Practical Linux Firewall Guide

If you apt install iptables on a current Debian, Ubuntu, or RHEL system, you get a wrapper around nft. The actual iptables kernel modules are gone from the default install path. nftables replaced iptables as the kernel's packet-filtering framework years ago, and the compatibility layer (iptables-nft) exists specifically so the old commands and scripts keep working while everything underneath has already changed. Understanding the real framework matters the moment you need something the compatibility shim doesn't cover, or you're debugging why a rule behaves differently than it did on an older box.

Why the kernel moved on #

Classic iptables evaluates four separate tables (filter, nat, mangle, raw), each with its own set of chains, and every table walks the packet through its own traversal. Rules are matched top-to-bottom with linear search, so a ruleset with thousands of entries gets measurably slower as it grows. nftables replaces this with a single in-kernel virtual machine: rules compile to bytecode, tables and chains are user-defined rather than fixed, and lookups can use actual data structures (sets, maps) instead of linear scans.

The practical differences that matter day to day:

| iptables | nftables
Rule evaluation | Linear scan per table | Compiled bytecode, can use sets/maps
Tables/chains | Fixed tables (filter, nat, mangle, raw) | User-defined, only what you declare
IPv4 + IPv6 | Separate tools (iptables, ip6tables) | One syntax, one ruleset
Rule syntax | One rule per -A command | Multiple rules in one statement block
Atomic reloads | Not atomic by default (iptables-restore helps) | Atomic by design (nft -f)
Default on current distros | Compatibility layer only (iptables-nft) | Native

Reading the same rule in both syntaxes #

A rule allowing established SSH traffic and dropping new connections from anywhere else:

bash.bash

# iptables
iptables -A INPUT -p tcp --dport 22 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -A INPUT -p tcp --dport 22 -j DROP

bash.bash

# nftables: same intent, one rule, no separate conntrack module needed
nft add rule inet filter input tcp dport 22 ct state established,related accept
nft add rule inet filter input tcp dport 22 drop

The inet family in nftables handles IPv4 and IPv6 in the same table, so there's no more maintaining parallel iptables and ip6tables rulesets that drift apart because someone forgot to update one of them.

A minimal nftables ruleset #

nftables rulesets are usually written as a file and loaded atomically, not built up rule-by-rule on the command line:

nft.nft

# /etc/nftables.conf
table inet filter {
chain input {
type filter hook input priority 0; policy drop;

ct state established,related accept
ct state invalid drop
iif "lo" accept

tcp dport 22 accept
tcp dport { 80, 443 } accept
ip protocol icmp accept

# log then drop everything else, rate-limited so it can't fill the disk
limit rate 5/minute log prefix "nft-drop: "
}

chain forward {
type filter hook forward priority 0; policy drop;
}

chain output {
type filter hook output priority 0; policy accept;
}
}

bash.bash

$ sudo nft -c -f /etc/nftables.conf # -c: check syntax only, don't load
$ sudo nft -f /etc/nftables.conf # load atomically, no partial-rule window
$ sudo systemctl enable --now nftables

The -c flag is worth building into any deploy pipeline that touches firewall rules: it validates the whole file before anything is applied, so a typo can't leave the box in a half-configured state mid-load.

Migrating an existing iptables ruleset #

Don't hand-translate a large ruleset. Use the conversion tool and review the output:

bash.bash

$ sudo iptables-save > /tmp/current-rules.iptables
$ sudo iptables-restore-translate -f /tmp/current-rules.iptables > /tmp/current-rules.nft
$ cat /tmp/current-rules.nft

iptables-restore-translate produces a working nftables ruleset from an iptables-save dump, but always read the output before loading it. Two things it won't fix for you:

Rule order semantics. iptables' first-match-wins per table can produce a different effective policy than a naively translated nftables chain if your original ruleset relied on jump targets across tables in a specific order. Walk through the translated file logically, not just syntactically.

Custom kernel modules. If your old rules used a niche -m match module with no nftables equivalent, the translator will flag it rather than silently drop it; check the tool's stderr output, not just the generated file.

Verifying and debugging live #

bash.bash

$ sudo nft list ruleset # the full active config, in nft syntax
$ sudo nft list chain inet filter input # just one chain
$ sudo nft monitor # live rule matches as they happen: the nftables equivalent of `iptables -L -v` counters, but real-time

nft monitor is the fastest way to confirm a rule is actually matching traffic instead of staring at packet counters that only update after the fact.

When to still reach for iptables syntax #

If you're running the iptables-nft compatibility layer (the default on any current distro), your existing iptables/iptables-save/iptables-restore scripts, Ansible playbooks, and CI checks keep working unchanged; they're translated to nftables rules under the hood automatically. There's no requirement to rewrite everything immediately. The case for migrating the syntax itself is when you're touching a ruleset anyway (new box, new deploy pipeline) and want atomic reloads, IPv4/IPv6 in one file, or a ruleset large enough that set-based lookups meaningfully beat linear scans. A few hundred rules and up is where that starts to show.

If you manage firewall rules across many hosts rather than one box's local iptables/nftables config, that's a different layer entirely; see zero-trust service-to-service auth with SPIFFE and SPIRE for identity-based access control instead of IP-based rules, and the broader Linux troubleshooting guide for the rest of the diagnostic toolkit this pairs with.

The call we'd make #

New box, new ruleset: write it in native nftables syntax from the start, since atomic reloads and one file for IPv4/IPv6 are worth it. Existing box with a working iptables ruleset and no active pain: leave the compatibility layer alone rather than migrating for its own sake. Either way, validate with nft -c before loading and confirm with nft monitor, not just packet counters, that a rule is doing what you think it's doing.