nftables is not a new firewall. It is the packet filtering engine that has been doing the work on your Linux boxes since kernel 3.13, quietly, underneath an iptables command that has been a translation layer for years. Learning nft is mostly learning to talk to something that is already running, and the payoff is a syntax where one rule replaces four, sets can be edited without touching rules, and a bad ruleset fails to load instead of half-applying.
This article is part of the Linux networking commands guide, and it assumes you have read the iptables fundamentals or already know them. Everything below was captured on a Debian 13 host bridged into a CML topology: the host is at 10.77.0.100 on ens224, and SRV1 at 10.77.3.10 is three router hops away running iperf3 on 5201 and a Python HTTP server on 8080. Every rule is scoped to that lab interface and subnet so the management path is never at risk.
Proof that you are already running it
Start with an empty box and look at what nft reports:
j@llmbits:~$ sudo nft --version
nftables v1.1.3 (Commodore Bullmoose #4)
j@llmbits:~$ sudo nft list ruleset
table ip filter {
chain INPUT {
type filter hook input priority filter; policy accept;
}
chain OUTPUT {
type filter hook output priority filter; policy accept;
}
}Those upper-case chain names are the giveaway. nft did not create them, iptables did, and the table is only there because something ran an iptables command at some point. Now write an iptables rule and look again through nft:
j@llmbits:~$ sudo iptables -A OUTPUT -o ens224 -d 10.77.3.10 -p tcp --dport 5201 -j DROP
j@llmbits:~$ sudo nft list ruleset
table ip filter {
chain INPUT {
type filter hook input priority filter; policy accept;
}
chain OUTPUT {
type filter hook output priority filter; policy accept;
ip daddr 10.77.3.10 oifname "ens224" tcp dport 5201 counter packets 0 bytes 0 drop
}
}Same rule, nftables syntax, because that is what it always was. This is the single most useful demonstration for anyone who thinks a migration is looming: the migration already happened, and the only decision left is which syntax you type.
It also explains a warning you will meet later. nftables knows which tables the compatibility layer owns and tells you to keep your hands off:
# Warning: table ip filter is managed by iptables-nft, do not touch!
# Warning: table ip6 filter is managed by iptables-nft, do not touch!Take it seriously. Mixing native nft rules into a table that iptables manages produces a ruleset that iptables -S cannot fully print, and eventually something runs iptables -F and deletes work you did with the other tool. Native rules go in their own table, always.
Translating what you already have
iptables-translate converts a rule without applying it. It is the fastest way to learn the syntax, because you write what you know and read back what it means:
j@llmbits:~$ /usr/sbin/iptables-translate -A OUTPUT -o ens224 -d 10.77.3.10 -p tcp --dport 5201 -j DROP
nft 'add rule ip filter OUTPUT oifname "ens224" ip daddr 10.77.3.10 tcp dport 5201 counter drop'
j@llmbits:~$ /usr/sbin/iptables-translate -A INPUT -i ens224 -p icmp --icmp-type echo-request -m limit --limit 2/sec -j ACCEPT
nft 'add rule ip filter INPUT iifname "ens224" icmp type echo-request limit rate 2/second burst 5 packets counter accept'
j@llmbits:~$ /usr/sbin/iptables-translate -t nat -A POSTROUTING -o ens224 -s 10.77.0.0/16 -j MASQUERADE
nft 'add rule ip nat POSTROUTING oifname "ens224" ip saddr 10.77.0.0/16 counter masquerade'It lives in /usr/sbin, so a non-root shell will tell you command not found unless you give the full path. There is a matching iptables-restore-translate that takes an entire iptables-save file and hands back an nftables ruleset, which is how you convert a real production policy rather than one rule at a time.
Reading those three translations side by side teaches most of the grammar. Match expressions are written as <family> <field> <value> (ip daddr 10.77.3.10, tcp dport 5201), interfaces are iifname and oifname, and the verdict goes at the end. There are no -m module loads, because the matches are built into the expression language.
Tables, chains and hooks: what actually changed
The concepts survive, but three of them are meaningfully different.
inet family filters IPv4 and IPv6 in the same rules. No more maintaining a parallel ip6tables policy that drifts out of sync.counter in it. Slightly more typing, measurably less overhead on rules you never inspect.Building a policy from scratch is three commands. Create a table, create a chain and tell it where to hook in, then add a rule:
j@llmbits:~$ sudo nft add table inet labfw
j@llmbits:~$ sudo nft add chain inet labfw output '{ type filter hook output priority 0 ; policy accept ; }'
j@llmbits:~$ sudo nft add rule inet labfw output oifname ens224 ip daddr 10.77.3.10 tcp dport 5201 counter rejectQuote the braces, or your shell will expand them before nft ever sees them. The result sits alongside the iptables-managed table without interfering with it:
j@llmbits:~$ sudo nft list ruleset
table ip filter {
chain INPUT {
type filter hook input priority filter; policy accept;
}
chain OUTPUT {
type filter hook output priority filter; policy accept;
}
}
table inet labfw {
chain output {
type filter hook output priority filter; policy accept;
oifname "ens224" ip daddr 10.77.3.10 tcp dport 5201 counter packets 0 bytes 0 reject with icmp port-unreachable
}
}
j@llmbits:~$ nc -zv -w 3 10.77.3.10 5201
nc: connect to 10.77.3.10 port 5201 (tcp) failed: Connection refused
j@llmbits:~$ nc -zv -w 3 10.77.3.10 8080
Connection to 10.77.3.10 8080 port [tcp/http-alt] succeeded!The priority number matters more than it looks. priority 0 is filter, and a chain at a lower number runs earlier on the same hook. That is the mechanism that lets a Docker-managed chain, a ufw-managed chain and your own chain all attach to the forward hook without any of them knowing about the others.
Handles: the answer to "delete just that one rule"
iptables identifies a rule by its position in the chain, which changes every time you insert something above it. nftables gives each rule a stable handle:
j@llmbits:~$ sudo nft -a list chain inet labfw output
table inet labfw {
chain output { # handle 1
type filter hook output priority filter; policy accept;
oifname "ens224" ip daddr 10.77.3.10 tcp dport 5201 counter packets 1 bytes 60 reject with icmp port-unreachable # handle 3
}
}
j@llmbits:~$ sudo nft delete rule inet labfw output handle 3
j@llmbits:~$ sudo nft list chain inet labfw output
table inet labfw {
chain output {
type filter hook output priority filter; policy accept;
}
}-a is the flag you will type most often. The handle stays attached to that rule for its lifetime, so a script can add a rule, record the handle and remove exactly that rule later without any risk of deleting whatever moved into position 3 in the meantime. Anyone who has written an automation that does iptables -D CHAIN 3 and eventually deleted the wrong rule knows what this is worth.
Sets: the feature worth migrating for
An anonymous set puts a list inside a rule, replacing multiport and its arbitrary limits:
j@llmbits:~$ sudo nft add rule inet labfw output oifname ens224 ip daddr 10.77.3.10 tcp dport '{ 22, 80, 5201, 8080 }' counter reject
j@llmbits:~$ sudo nft list chain inet labfw output
table inet labfw {
chain output {
type filter hook output priority filter; policy accept;
oifname "ens224" ip daddr 10.77.3.10 tcp dport { 22, 80, 5201, 8080 } counter packets 0 bytes 0 reject with icmp port-unreachable
}
}
j@llmbits:~$ nc -zv -w 2 10.77.3.10 5201
nc: connect to 10.77.3.10 port 5201 (tcp) failed: Connection refused
j@llmbits:~$ nc -zv -w 2 10.77.3.10 8080
nc: connect to 10.77.3.10 port 8080 (tcp) failed: Connection refusedThat is convenience. The real feature is the named set, which exists independently of the rules that reference it:
j@llmbits:~$ sudo nft flush chain inet labfw output
j@llmbits:~$ sudo nft add set inet labfw blocked_ports '{ type inet_service ; }'
j@llmbits:~$ sudo nft add rule inet labfw output oifname ens224 ip daddr 10.77.0.0/16 tcp dport @blocked_ports counter drop
j@llmbits:~$ nc -zv -w 2 10.77.3.10 5201
Connection to 10.77.3.10 5201 port [tcp/*] succeeded!The rule is live but the set is empty, so nothing matches. Now add an element, and the policy changes with no rule edit at all:
j@llmbits:~$ sudo nft add element inet labfw blocked_ports '{ 5201 }'
j@llmbits:~$ nc -zv -w 3 10.77.3.10 5201
nc: connect to 10.77.3.10 port 5201 (tcp) timed out: Operation now in progress
j@llmbits:~$ nc -zv -w 2 10.77.3.10 8080
Connection to 10.77.3.10 8080 port [tcp/http-alt] succeeded!
j@llmbits:~$ sudo nft list set inet labfw blocked_ports
table inet labfw {
set blocked_ports {
type inet_service
elements = { 5201 }
}
}
j@llmbits:~$ sudo nft delete element inet labfw blocked_ports '{ 5201 }'
j@llmbits:~$ nc -zv -w 3 10.77.3.10 5201
Connection to 10.77.3.10 5201 port [tcp/*] succeeded!Think about what that means operationally. A blocklist becomes a set of type ipv4_addr that a script feeds from a threat feed, and updating it is one add element call that never touches the ruleset. Sets can carry a timeout, so entries expire on their own, and a flags interval set accepts CIDR ranges. Matching is a hash or interval lookup rather than a linear walk, so a set with ten thousand entries costs roughly what a set with ten costs. The iptables equivalent was ipset, a separate tool with separate persistence; here it is part of the same object.
The related construct is the verdict map, which takes the lookup one step further and stores the verdict itself: tcp dport vmap { 22 : accept, 5201 : drop } replaces a chain of comparisons with a single lookup.
The ruleset as a file, loaded atomically
This is how nftables is meant to be run in production. Write the whole policy as a file:
j@llmbits:~$ cat /tmp/labfw.nft
#!/usr/sbin/nft -f
table inet labfw {
set allowed_ports {
type inet_service
elements = { 8080 }
}
chain lab_out {
type filter hook output priority filter; policy accept;
# only ever look at traffic leaving the lab NIC
oifname != "ens224" accept
ip daddr != 10.77.0.0/16 accept
ct state established,related accept
tcp dport @allowed_ports counter accept
icmp type echo-request limit rate 2/second counter accept
counter log prefix "labfw-drop " drop
}
}Load it in one command:
j@llmbits:~$ sudo nft -f /tmp/labfw.nft
j@llmbits:~$ sudo nft list ruleset
table inet labfw {
set allowed_ports {
type inet_service
elements = { 8080 }
}
chain lab_out {
type filter hook output priority filter; policy accept;
oifname != "ens224" accept
ip daddr != 10.77.0.0/16 accept
ct state established,related accept
tcp dport @allowed_ports counter packets 0 bytes 0 accept
icmp type echo-request limit rate 2/second burst 5 packets counter packets 0 bytes 0 accept
counter packets 0 bytes 0 log prefix "labfw-drop " drop
}
}Several things in that file are worth calling out. The two != rules at the top are a scoping guard: anything not leaving ens224 for the lab range is accepted immediately, which means the policy below can never touch the management path no matter what you add to it. Comments are supported in the file but do not survive the load, as the listing above shows, so keep the file in version control and treat the loaded ruleset as build output rather than the source of truth. And counter log prefix "..." drop is three statements in one rule, where iptables needed two separate rules because LOG was a non-terminating target.
Testing it, with the counters read after an HTTP request, a blocked connection attempt and the ping test from the next section:
j@llmbits:~$ curl -sS -m 5 -o /dev/null -w 'HTTP %{http_code}\n' http://10.77.3.10:8080/
HTTP 200
j@llmbits:~$ nc -zv -w 3 10.77.3.10 5201
nc: connect to 10.77.3.10 port 5201 (tcp) timed out: Operation now in progress
j@llmbits:~$ sudo nft list chain inet labfw lab_out
chain lab_out {
type filter hook output priority filter; policy accept;
oifname != "ens224" accept
ip daddr != 10.77.0.0/16 accept
ct state established,related accept
tcp dport @allowed_ports counter packets 1 bytes 60 accept
icmp type echo-request limit rate 2/second burst 5 packets counter packets 1 bytes 84 accept
counter packets 3 bytes 180 log prefix "labfw-drop " drop
}And the log statement writes to the kernel ring buffer exactly like the iptables LOG target:
j@llmbits:~$ sudo dmesg | grep labfw-drop | tail -3
[240564.108550] labfw-drop IN= OUT=ens224 SRC=10.77.0.100 DST=10.77.3.10 LEN=60 TOS=0x00 PREC=0x00 TTL=64 ID=27867 DF PROTO=TCP SPT=41692 DPT=5201 WINDOW=64240 RES=0x00 SYN URGP=0An honest result: the rate limiter that did not limit
The ping test in that run came back clean, and it should not have:
j@llmbits:~$ sudo ping -c 10 -i 0.1 -W 1 10.77.3.10 | tail -3
10 packets transmitted, 10 received, 0% packet loss, time 906ms
rtt min/avg/max/mdev = 4.614/5.402/6.950/0.584 msTen pings in under a second against a two-per-second limiter, and none were dropped. The counter listing above is the explanation: the ICMP rule matched exactly one packet across the whole test. The other nine never reached it, and the only rule above that could have accepted them is ct state established,related accept, because connection tracking treats an ICMP echo exchange as a flow and marks everything after the first request as ESTABLISHED. That rule carries no counter, which is a good argument for putting one on every rule you might ever need to reason about.
This is the ordering trap that catches everyone who puts a stateful accept at the top of a chain and then tries to rate limit below it. The conntrack rule is the right first rule for TCP performance, and it silently defeats any per-packet limiter placed after it. If you want the limit to apply, match on ct state new in the limiter, or put the limiter above the stateful accept. Real output beats a diagram here, which is exactly why we run these in the lab rather than reasoning about them.
Atomic loading, demonstrated
Break the file deliberately, referencing a set that does not exist, and reload:
j@llmbits:~$ sed -i 's/tcp dport @allowed_ports/tcp dport @no_such_set/' /tmp/labfw.nft
j@llmbits:~$ sudo nft -f /tmp/labfw.nft
/tmp/labfw.nft:17:27-38: Error: No such file or directory
tcp dport @no_such_set counter accept
^^^^^^^^^^^^
j@llmbits:~$ sudo nft list chain inet labfw lab_out
chain lab_out {
type filter hook output priority filter; policy accept;
oifname != "ens224" accept
ip daddr != 10.77.0.0/16 accept
ct state established,related accept
tcp dport @allowed_ports counter packets 1 bytes 60 accept
icmp type echo-request limit rate 2/second burst 5 packets counter packets 1 bytes 84 accept
counter packets 3 bytes 180 log prefix "labfw-drop " drop
}The load failed, and the running ruleset is untouched, counters and all. iptables-restore commits one table at a time, so a bad line aborts that table but can still leave earlier tables in the same file applied, which on a policy spanning filter, nat and mangle is a genuinely awkward state to be in. nftables scopes the transaction to the whole file. The error message even points at the column. This is the single strongest argument for driving nftables from a file instead of a sequence of add rule commands.
Backup is the mirror image:
j@llmbits:~$ sudo nft list ruleset > /tmp/ruleset.bakThat output is itself a valid input file, so nft -f /tmp/ruleset.bak restores it. On Debian and Ubuntu the persistent path is /etc/nftables.conf, loaded at boot by the nftables.service unit, and like iptables nothing survives a reboot unless something reloads it.
Teardown, and a warning about half-cleanups
j@llmbits:~$ sudo nft delete table inet labfw
j@llmbits:~$ sudo nft list ruleset
table ip filter {
chain INPUT {
type filter hook input priority filter; policy accept;
}
chain OUTPUT {
type filter hook output priority filter; policy accept;
}
}
table ip nat {
chain OUTPUT {
type nat hook output priority dstnat; policy accept;
}
}
j@llmbits:~$ nc -zv -w 3 10.77.3.10 5201
Connection to 10.77.3.10 5201 port [tcp/*] succeeded!Note what is left behind: the ip filter and ip nat tables are still there, empty, because those belong to the iptables compatibility layer and were created the moment somebody ran an iptables command earlier in the session. Deleting your own table does not touch them, which is exactly the separation you want.
One command removes the table, its chains, its sets and every rule in it. That is the practical benefit of owning your own table: cleanup is atomic and total, where iptables needs a flush, a chain delete and a policy reset per table per address family.
The commands you will actually use day to day are a short list:
nft list rulesetnft -a list chain <fam> <table> <chain>nft -f <file>nft add/delete elementnft delete table <fam> <name>nft monitorShould you migrate?
Honest answer: not urgently, and not by rewriting a working policy for its own sake. Your iptables rules already run on this engine. The cases where it pays are specific.
Migrate when your policy has a list that changes: blocklists, allowlists of management sources, per-customer ranges. Named sets turn a rule-editing problem into a data problem. Migrate when you maintain parallel IPv4 and IPv6 rulesets, because an inet table halves that work and eliminates the drift where someone adds a rule to one family and forgets the other. Migrate when a partial rule load would be an outage, because atomic loading is a real safety property. And migrate when you are automating, because handles give you a stable identifier that positions never did.
Stay where you are when the policy is twenty static rules that have not changed in two years. There is no prize for the rewrite, and the compatibility layer is maintained.
Key takeaways
- Your iptables rules are already nftables rules.
iptables -A ...followed bynft list rulesetshows the same rule in nftables syntax. - Never add native
nftrules to a table that nftables labels managed by iptables-nft. Create your own table. iptables-translate(in/usr/sbin) converts a rule without applying it, andiptables-restore-translateconverts a whole saved policy.- An
inetfamily table filters IPv4 and IPv6 with one set of rules. - Counters are opt-in. Write
counterin any rule you intend to troubleshoot. nft -ashows handles, which are stable rule identifiers. Delete by handle, never by position.- Named sets change what a policy matches without editing the policy, support timeouts and CIDR intervals, and match in constant time.
- A ruleset file loaded with
nft -fis all-or-nothing. A syntax error leaves the running ruleset completely untouched. - A
ct state established,related acceptrule placed above a rate limiter will absorb the traffic you meant to limit. Matchct state newin the limiter instead. nft delete table inet <name>removes chains, sets, rules and all in one atomic step.
nftables is the engine, iptables is the syntax most of the world still writes, and ufw is the front end for the days you need one port open and nothing more. All three sit in the firewall section of the complete guide to Linux networking commands, alongside the capture and socket tools you will reach for when a rule does not behave the way you expected.