A how-to manual: families and tables, handles, atomic rulesets, sets and maps, dynamic meters, chain priorities, NAT, tracing and safe changes, all tested on a lab
▶Prefer to watch? This video runs the guide's main commands on a real Ubuntu 24.04 machine, with on-screen captions and no voiceover. Click to play, or watch it on YouTube. It is part of the Networking and Firewalls playlist.
nftables: tables, sets and one ruleset for everything
nft list ruleset
nftables is the packet filter that replaced iptables in the Linux kernel. It does the same job (decide what happens to each packet), with one tool called nft instead of four (iptables, ip6tables, arptables, ebtables), one syntax instead of a pile of options, sets and maps built in instead of bolted on, and a ruleset you load in one atomic step. If iptables is a list of commands you type in order, nftables is a small programming language for packets, and the file you write in it is also what the tool prints back at you. That last part is the nicest thing about it: you can read a ruleset like a document.
This guide builds nftables from nothing, tests what it does, and covers the things that are genuinely different from iptables, because those are where people trip: handles, chain priorities, the flush ruleset line, and the way a rule file either loads completely or not at all.
Every example below ran for real: nftables v1.0.9 on Ubuntu 24.04, in a lab with two virtual clients, one on an "inside" LAN (192.168.60.0/24, fd00:60::/64, interface eth1) and one "outside" (198.51.100.0/24, 2001:db8:2::/64, interface eth2), and real listeners on ports 22, 80, 443, 3306, 8080 and 9000. "probe" in the examples is a small test script: it connects from a client and prints what that client really experienced (open, refused, or silence). The lab ran as root; use sudo on a real server. Anything not run in the lab is marked "not run in the lab".
nftables and iptables: what changed
Both are front ends for Netfilter, the packet filtering framework in the kernel. iptables came first (2001) and grew an extension for every need: a different command for IPv4, IPv6, ARP and bridges, a different match module for each trick, no sets (hence the separate ipset tool), and rules added one at a time with the machine in a half-finished state in between. nftables (kernel 3.13, 2014) was written as the replacement:
one command: nft, for IPv4, IPv6, ARP, bridges and plain network devices
one ruleset language, with the same readable form in and out
sets, maps, dictionaries and concatenations built in (an address together with a port)
a whole ruleset loads in one atomic transaction: all of it, or nothing
the rule set lives in the kernel as small programs, so a lookup in a big set costs the same as in a small one
you create your own tables and chains, so there are no "built-in" chains that always exist and always run
Today the iptables command on most distributions (Debian 10+, Ubuntu 20.04+, RHEL 8+) is "iptables-nft", a translator that stores its rules in nftables. So even on a machine that "uses iptables", nftables is underneath, and nft can show you the result. Check the version of nft and see what is already there:
$ nft --version
nftables v1.0.9 (Old Doc Yak #3)
$ nft list ruleset ; echo "($(nft list ruleset | wc -l) lines)"
(0 lines)
Nothing: no tables, no rules, everything open. Install it with apt install nftables or dnf install nftables if it is missing.
The model: families, tables, chains, rules, sets
Four levels, from the top:
family which kind of traffic a table sees: ip (IPv4), ip6 (IPv6), inet (both at once), arp, bridge, netdev (a single network card, very early; not run in the lab). The family you will use most is inet.
table a named container that you create, inside one family. Nothing exists until you make it. One table per purpose (filter, nat, a blocklist) keeps things apart.
chain a list of rules in a table. A BASE chain is attached to a place in the packet's path (a "hook": prerouting, input, forward, output, postrouting) and has a type (filter, nat, route), a priority and a policy. A REGULAR chain is a plain list, which you reach with jump or goto, like a subroutine.
rule the expressions to match, then a verdict or an action: accept, drop, reject, counter, log, jump, snat, dnat, masquerade ...
Beside the rules, a table can hold:
set a collection of addresses, ports, networks and so on, with a type; used inside rules (@name)
map like a set, but each element carries a value (port 8443 maps to the address 192.168.60.20); verdict maps hold verdicts
counter, quota, limit named objects, shared between rules
Hooks and priorities (the same idea as the iptables tables and chains, with different names):
prerouting packets arriving, before the routing decision
input packets for this machine
forward packets passing through
output packets this machine creates
postrouting packets leaving
Priorities decide the order of base chains that sit on the SAME hook, lowest number first. The named ones are raw (-300), mangle (-150), dstnat (-100), filter (0), security (50), srcnat (100). "Filter" is the usual one for firewalls. That is a big difference from iptables, and has its own section below.
A first look, and building a ruleset by hand
Ubuntu ships a skeleton in /etc/nftables.conf. Here it is, as installed:
$ cat /etc/nftables.conf
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
chain input {
type filter hook input priority filter;
}
chain forward {
type filter hook forward priority filter;
}
chain output {
type filter hook output priority filter;
}
}
It is a complete example of the syntax: a table in the inet family, three base chains on the three filtering hooks, nothing in them. Note: no policy given means accept. Build the same thing by hand, one command at a time. The semicolons are escaped with a backslash only because the shell would eat them; in a file they are plain:
$ nft add table inet filter
$ nft add chain inet filter input { type filter hook input priority 0 \; policy accept \; }
$ nft list ruleset
table inet filter {
chain input {
type filter hook input priority filter; policy accept;
}
}
$ nft add rule inet filter input tcp dport 3306 counter drop
$ probe c1 192.168.60.1 22 3306 ; probe c1 fd00:60::1 3306
c1 -> 192.168.60.1 port 22 open: port 22: you are 192.168.60.20
c1 -> 192.168.60.1 port 3306 no answer (timeout)
c1 -> fd00:60::1 port 3306 no answer (timeout)
$ nft list ruleset
table inet filter {
chain input {
type filter hook input priority filter; policy accept;
tcp dport 3306 counter packets 6 bytes 420 drop
}
}
Three things to see. nft prints exactly what it will accept as input, so copy and paste works. The counter shows 6 packets, because "counter" is a statement you ask for in a rule (unlike iptables, counters are not on by default). And the one rule dropped port 3306 over IPv4 AND IPv6, since the table is in the inet family. Commands accept the same words in any order of table and chain; "nft add" and "nft insert" and "nft delete" are verbs, "nft list" and "nft flush" show and clear.
Handles: add, insert, replace and delete
Every rule has a HANDLE, a stable number assigned when it is created. You do not address rules by their position in the list (as iptables does) but by handle, and listing with -a shows them:
$ nft -a list chain inet filter input
table inet filter {
chain input { # handle 1
type filter hook input priority filter; policy accept;
tcp dport 3306 counter packets 6 bytes 420 drop # handle 2
}
}
add puts a rule at the END (or after a given one); insert puts it at the TOP:
$ nft add rule inet filter input tcp dport 22 accept
$ nft insert rule inet filter input ip saddr 192.168.60.20 tcp dport 8080 drop
$ nft -a list chain inet filter input
chain input { # handle 1
type filter hook input priority filter; policy accept;
ip saddr 192.168.60.20 tcp dport 8080 drop # handle 4
tcp dport 3306 counter packets 6 bytes 420 drop # handle 2
tcp dport 22 accept # handle 3
}
THE TRAP. "position" does not mean "line number", it means "after the rule with this HANDLE". The next command asks for position 3 and the new rule goes after the rule with handle 3 (the ssh rule). It looks like a line number only because handle 3 happens to be the third line here. Order in the list is not order of handles: the rule inserted last (handle 4) is on top.
$ nft add rule inet filter input position 3 tcp dport 9000 reject
$ nft -a list chain inet filter input
ip saddr 192.168.60.20 tcp dport 8080 drop # handle 4
tcp dport 3306 counter packets 6 bytes 420 drop # handle 2
tcp dport 22 accept # handle 3
tcp dport 9000 reject # handle 5
Deleting and replacing work on handles too. A handle that does not exist is an error, and a handle that DOES exist is deleted or replaced, whether or not it is the one you meant:
$ nft delete rule inet filter input handle 6
Error: Could not process rule: No such file or directory
delete rule inet filter input handle 6
^
$ nft replace rule inet filter input handle 4 tcp dport 3306 counter reject with tcp reset
$ probe c1 192.168.60.1 3306 8080 9000
c1 -> 192.168.60.1 port 3306 Connection refused
c1 -> 192.168.60.1 port 8080 open: port 8080: you are 192.168.60.20
c1 -> 192.168.60.1 port 9000 Connection refused
Look at port 8080: it is open again. The intention was to change the 3306 rule, but handle 4 was the 8080 rule: it was replaced, and the block on 8080 disappeared. Always run nft -a list first and read the handle off the line you mean. (The lab did this by accident, which is how it was found.)
Deleting a chain deletes its rules with it, which is friendlier than iptables. Only something that still POINTS at the chain stops it:
$ nft -f chains.nft # chain "mine" with a rule, chain "caller" that has: jump mine
$ nft delete chain inet filter mine
Error: Could not process rule: Device or resource busy
$ nft flush chain inet filter caller ; nft delete chain inet filter mine ; nft delete chain inet filter caller
$ nft add chain inet filter tmp ; nft add rule inet filter tmp counter ; nft delete chain inet filter tmp ; echo "rc=$?"
rc=0
"add" is quiet when the thing already exists (adding a table that is there is not an error), and "create" is the strict version:
$ nft add table inet filter # exists already: no output, no error
$ nft create table inet filter
Error: Could not process rule: File exists
Last, the policy. Set it on the chain, after the allow rules exist:
$ nft chain inet filter input { policy drop \; }
$ probe c1 192.168.60.1 22 80
c1 -> 192.168.60.1 port 22 open: port 22: you are 192.168.60.20
c1 -> 192.168.60.1 port 80 no answer (timeout)
Same rule as with iptables: allow ESTABLISHED and RELATED traffic first (the replies to what the machine itself started) and the DROP policy last. In a rule file all that happens in one step, which is the next section.
The ruleset file: one atomic load
A command at a time is fine for learning. For a real machine, write the whole thing in a file and load it with nft -f. The load is a TRANSACTION: every change in the file is applied together or not at all, and traffic never meets a half-built firewall. This is the baseline from the lab, /root/base.nft:
#!/usr/sbin/nft -f
flush ruleset
define LAN = 192.168.60.0/24
define LAN6 = fd00:60::/64
define WEB_PORTS = { 80, 443 }
table inet filter {
set admins {
type ipv4_addr
flags interval
elements = { 192.168.60.20, 10.0.0.0/8 }
}
chain input {
type filter hook input priority filter; policy drop;
ct state invalid drop
ct state established,related accept
iif lo accept
meta l4proto { icmp, ipv6-icmp } limit rate 5/second accept
tcp dport 22 ip saddr @admins accept comment "ssh from admins only"
tcp dport $WEB_PORTS accept comment "web"
counter comment "everything else"
}
chain forward {
type filter hook forward priority filter; policy drop;
}
chain output {
type filter hook output priority filter; policy accept;
}
}
Read it: "flush ruleset" empties everything first (more on that line below); "define" makes a variable; the table has a SET of admin addresses (a network and a single host, hence flags interval), and an input chain with the policy drop. The same two things as in iptables come first (ESTABLISHED,RELATED, loopback), then the services. The last rule "counter" has no verdict: it only counts what reached the end, which is exactly the traffic the policy will drop, so it doubles as a "what is being refused" meter. Check the syntax without applying anything, then load it:
$ nft -c -f /root/base.nft ; echo "rc=$?"
rc=0
$ nft -f /root/base.nft ; echo "rc=$?"
rc=0
$ nft list ruleset
table inet filter {
set admins {
type ipv4_addr
flags interval
elements = { 10.0.0.0/8, 192.168.60.20 }
}
chain input {
type filter hook input priority filter; policy drop;
ct state invalid drop
ct state established,related accept
iif "lo" accept
meta l4proto { icmp, ipv6-icmp } limit rate 5/second burst 5 packets accept
tcp dport 22 ip saddr @admins accept comment "ssh from admins only"
tcp dport { 80, 443 } accept comment "web"
counter packets 0 bytes 0 comment "everything else"
}
...
}
$ probe c1 192.168.60.1 22 80 3306 ; probe c1 192.168.60.1 22 -s 192.168.60.21 ; probe c1 fd00:60::1 80 3306
c1 -> 192.168.60.1 port 22 open: port 22: you are 192.168.60.20
c1 -> 192.168.60.1 port 80 open: port 80: you are 192.168.60.20
c1 -> 192.168.60.1 port 3306 no answer (timeout)
c1 -> 192.168.60.1 port 22 no answer (timeout)
c1 -> fd00:60::1 port 80 open: port 80: you are fd00:60::20
c1 -> fd00:60::1 port 3306 no answer (timeout)
ssh works for the admin address .20 and not for the other LAN address .21, web works over both IPv4 and IPv6, and everything else is dropped, with the same file for both protocols. The variables are expanded when the file is read (the ruleset has { 80, 443 } and no variable). Notice also what nft changed on the way out: "iif lo" became iif "lo", the limit got an explicit burst, and the order is kept. The listing is canonical: loading that output gives the same ruleset back, which is how you can save a running configuration (see the persistence section).
THE LINE THAT BITES: "flush ruleset" removes EVERY table in EVERY family: your own, and the ones made by iptables-nft, firewalld, ufw, Docker, fail2ban, libvirt, Kubernetes. On a machine that runs any of them, a file that starts with flush ruleset silently deletes their rules. The lab proved it:
$ iptables -A INPUT -p tcp --dport 8888 -j ACCEPT ; iptables -S INPUT | grep 8888
-A INPUT -p tcp -m tcp --dport 8888 -j ACCEPT
$ nft flush ruleset ; iptables -S INPUT | grep -c 8888
0
Safer: replace only YOUR table. The usual idiom, at the top of the file instead of flush ruleset, is three steps that stay inside the same atomic load: declare the table ("table inet filter", which creates it if it is missing and does nothing if it exists), then "delete table inet filter" (now it always exists, so the delete cannot fail), then the real definition. Everything else on the machine is left alone. Even better, give your tables a name of your own (inet mycorp) so nobody else's tables are ever in the way. (The declare-delete idiom is not run in the lab; the plain flush ruleset was.)
What nft says when you make a mistake
nft's error messages are the best in the business: they quote your line, point at the exact word with carets, and sometimes tell you what you probably meant. All of these were produced from the baseline file by changing one word, and -c checks without loading:
$ nft -c -f bad1.nft # "dprot" instead of "dport"
/root/bad1.nft:23:7-11: Error: syntax error, unexpected string
tcp dprot 22 ip saddr @admins accept comment "ssh from admins only"
^^^^^
$ nft -c -f bad2.nft # the opening brace of a chain is missing
/root/bad2.nft:16:13-13: Error: syntax error, unexpected newline, expecting '{'
chain input
^
$ nft -c -f bad3.nft # a set that does not exist (@admin for @admins)
/root/bad3.nft:23:25-30: Error: No such file or directory; did you mean set 'admins' in table inet 'filter'?
tcp dport 22 ip saddr @admin accept comment "ssh from admins only"
^^^^^^
$ nft -c -f bad4.nft # a policy that is not a policy
/root/bad4.nft:17:50-54: Error: syntax error, unexpected string, expecting accept or drop or '$'
type filter hook input priority filter; policy dropp;
^^^^^
$ nft -c -f bad5.nft # ip6 saddr matched against an IPv4 set
/root/bad5.nft:23:26-32: Error: datatype mismatch, expected IPv6 address, expression has type IPv4 address
tcp dport 22 ip6 saddr @admins accept comment "ssh from admins only"
~~~~~~~~~ ^^^^^^^
$ nft -c -f bad6.nft # overlapping networks in an interval set
/root/bad6.nft:14:31-45: Error: conflicting intervals specified
elements = { 192.168.60.20, 192.168.60.0/24, 192.168.60.0/25 }
^^^^^^^^^^^^^^^ ~~~~~~~~~~~~~~~
The same set of failures one command at a time, from the lab (a number of these are worth recognizing at a glance):
nft add chain inet nosuch input Error: No such file or directory (the table does not exist)
nft add rule inet filter nochain accept Error: Could not process rule: No such file or directory (the chain does not exist)
nft add rule inet filter input tcp dport 22 accep Error: syntax error, unexpected newline
nft add rule inet filter input ip saddr 999.1.1.1 drop Error: Could not resolve hostname: Name or service not known
nft add rule inet filter input tcp dport 70000 drop Error: Service out of range
nft add rule ip6 nosuch x drop Error: No such file or directory; did you mean table 'filter' in family ip?
nft add element inet filter bad { 10.0.0.1 . 80-90, 10.0.0.1 . 85 } Error: Could not process rule: File exists (overlapping range element)
nft -f /nonexistent.nft internal:0:0-0: Error: Could not open file "/nonexistent.nft": No such file or directory
nft list ruleset (as a normal user) Operation not permitted (you must be root)
The hostname one is a surprise: a bad address is not "invalid", nft tries to resolve it as a NAME, and reports the lookup failure, which can be slow on a broken resolver. Quote the addresses you mean.
And the most important property: a failed load changes NOTHING. The broken file was loaded for real, it failed, and the running firewall carried on:
$ nft -f /root/bad1.nft ; echo "rc=$?"
/root/bad1.nft:23:7-11: Error: syntax error, unexpected string
rc=1
$ nft list ruleset | grep -c "chain input" ; probe c1 192.168.60.1 80
1
c1 -> 192.168.60.1 port 80 open: port 80: you are 192.168.60.20
This is the main reason to prefer a file to a series of commands.
One table for IPv4 and IPv6: the inet family
With iptables you need two rule sets, and the common hole is the second one that was never written. In an inet table one rule sees both. Look at the baseline results above: port 80 open and 3306 dropped on 192.168.60.1 AND on fd00:60::1, from a single set of rules. Where you need to be specific the rule says which: ip saddr 10.0.0.0/8 matches IPv4 only, ip6 saddr fd00::/8 IPv6 only, and meta nfproto ipv4 matches the protocol. A rule with only tcp dport applies to both. Things that differ between the two (ICMP versus ICMPv6) are handled by naming both protocols, as the baseline does: meta l4proto { icmp, ipv6-icmp }. IPv6 needs ICMPv6 to work at all (neighbor discovery), so a default-drop IPv6 policy has to let it through.
Sets: named, live, with timeouts
A set is a first-class object: it has a name and a type, lives in a table, and can be changed while the firewall runs, without touching the rules that use it. The baseline's admins set is one, and the rule "ip saddr @admins" is the only place it is used:
$ nft add element inet filter admins { 192.168.60.21 }
$ probe c1 192.168.60.1 22 -s 192.168.60.21
c1 -> 192.168.60.1 port 22 open: port 22: you are 192.168.60.21
$ nft delete element inet filter admins { 192.168.60.21 }
$ probe c1 192.168.60.1 22 -s 192.168.60.21
c1 -> 192.168.60.1 port 22 no answer (timeout)
One command added an address, and one removed it, and the next packet obeyed. The common types: ipv4_addr, ipv6_addr, inet_service (ports), ether_addr, ifname, mark, and concatenations of them. flags interval allows networks and ranges (10.0.0.0/8, 80-90).
A set with timeouts makes entries that remove themselves. That is a ready-made temporary block list (the thing fail2ban builds out of rules):
$ nft add set inet filter blocked { type ipv4_addr \; flags timeout \; }
$ nft insert rule inet filter input ip saddr @blocked drop
$ nft add element inet filter blocked { 192.168.60.20 timeout 6s }
$ nft list set inet filter blocked
table inet filter {
set blocked {
type ipv4_addr
flags timeout
elements = { 192.168.60.20 timeout 6s expires 5s992ms }
}
}
$ probe c1 192.168.60.1 80
c1 -> 192.168.60.1 port 80 no answer (timeout)
$ sleep 7 ; nft list set inet filter blocked ; probe c1 192.168.60.1 80
table inet filter {
set blocked {
type ipv4_addr
flags timeout
}
}
c1 -> 192.168.60.1 port 80 open: port 80: you are 192.168.60.20
The entry had an expiry of six seconds, and after seven it was gone, the set empty and the client back in. A set can also have a default for all its elements: timeout 1h in the set definition. A tool that detects an attacker (a log watcher, a script) only has to run nft add element ... timeout 1h; there is nothing to clean up afterwards. (Not run in the lab: a default timeout in the set definition, which the next section uses.)
Dynamic sets: rate limits and connection counts per address
The rate limit in the baseline (limit rate 5/second) is one counter shared by everybody. To limit EACH address separately, nftables adds the source address to a set on the fly, with its own limiter. This is called a meter, and it is a set with the flag dynamic:
$ nft add set inet filter ssh_meter { type ipv4_addr \; flags dynamic,timeout \; timeout 1m \; }
$ nft insert rule inet filter input tcp dport 22 ct state new add @ssh_meter { ip saddr limit rate over 3/minute burst 3 packets } counter drop
Read the rule: for a NEW connection to port 22, put the source address into the meter, with a limit; if that address has gone OVER three a minute, drop. The first three get in, then it stops:
$ for i in 1 2 3 4 5 6 ; do probe c1 192.168.60.1 22 ; done
c1 -> 192.168.60.1 port 22 open: port 22: you are 192.168.60.20
c1 -> 192.168.60.1 port 22 open: port 22: you are 192.168.60.20
c1 -> 192.168.60.1 port 22 open: port 22: you are 192.168.60.20
c1 -> 192.168.60.1 port 22 no answer (timeout)
c1 -> 192.168.60.1 port 22 no answer (timeout)
c1 -> 192.168.60.1 port 22 no answer (timeout)
$ nft list set inet filter ssh_meter
set ssh_meter {
type ipv4_addr
size 65535
flags dynamic,timeout
timeout 1m
elements = { 192.168.60.20 limit rate over 3/minute burst 3 packets expires 47s480ms, ... }
}
$ nft flush set inet filter ssh_meter ; probe c1 192.168.60.1 22
c1 -> 192.168.60.1 port 22 open: port 22: you are 192.168.60.20
Each address gets its own entry, with its own limit and expiry; the set cleans itself after the timeout (a minute here), and flush set forgives everybody. The size line is the default maximum number of entries (65535): a flood of spoofed addresses is why you do not leave it unlimited. The meter also lists addresses that the later rules would have refused anyway (the lab's other address, .21, which is not an admin, shows up in the set): it counts every NEW packet that reaches it, so put it where you want the counting to start.
The other per-address limit is on how many connections are open at the same time (iptables' connlimit). The lab's backend holds each connection for six seconds, and the rule allows two per source address:
$ nft add set inet filter conns { type ipv4_addr \; flags dynamic \; size 65536 \; }
$ nft insert rule inet filter input tcp dport 8081 add @conns { ip saddr ct count over 2 } reject with tcp reset
$ nft add rule inet filter input tcp dport 8081 accept
$ ip netns exec c1 python3 many.py # four simultaneous connections
connection 1: open: holding
connection 2: open: holding
connection 3: Connection refused
connection 4: Connection refused
Concatenations, maps and verdict maps
Concatenation puts several fields in one key: a set of address-and-port pairs, with ranges on either part. That replaces the list of rules that iptables needs for "this host may reach this port":
$ nft add set inet filter allowed { type ipv4_addr . inet_service \; flags interval \; elements = { 192.168.60.20 . 3306, 192.168.60.0/24 . 8080-8090 } \; }
$ nft insert rule inet filter input ip saddr . tcp dport @allowed accept
$ probe c1 192.168.60.1 3306 8080
c1 -> 192.168.60.1 port 3306 open: port 3306: you are 192.168.60.20
c1 -> 192.168.60.1 port 8080 open: port 8080: you are 192.168.60.20
$ probe c1 192.168.60.1 3306 8080 -s 192.168.60.21
c1 -> 192.168.60.1 port 3306 no answer (timeout)
c1 -> 192.168.60.1 port 8080 open: port 8080: you are 192.168.60.21
Port 3306 only for .20 (the pair), while the whole /24 may use ports 8080 to 8090. A verdict map chooses the action from a key, so a list of "if port X, do Y" rules becomes one lookup:
$ nft add chain inet filter webchain
$ nft add rule inet filter webchain counter accept
$ nft add rule inet filter input tcp dport vmap { 9000 : accept, 6000 : drop, 7000 : jump webchain }
$ nft list chain inet filter input | tail -3
tcp dport vmap { 6000 : drop, 7000 : jump webchain, 9000 : accept }
}
$ probe c1 192.168.60.1 9000
c1 -> 192.168.60.1 port 9000 open: port 9000: you are 192.168.60.20
And an ordinary map returns a VALUE: the NAT section uses one to forward many ports with one rule. Both kinds can be named, live objects like sets (add map, add element).
Chain priorities: why an accept does not always accept
Here is the difference that surprises people coming from iptables. In iptables there is one INPUT chain per table, and "ACCEPT" ends the packet's journey through it. In nftables ANY number of tables can attach a base chain to the same hook, and each of those chains is evaluated, in priority order. An accept in one chain only ends THAT chain: the packet still goes on to the next chain on the same hook, which may drop it. A drop anywhere ends it for good.
That is how firewalld, Docker, fail2ban and your own file can live side by side, and how you lose an afternoon. The lab: the baseline accepts ssh. A second table, with its own input chain at priority 10 (after the filter chain, which is 0), drops it:
$ nft list ruleset | grep -E '^table|hook'
table inet filter {
type filter hook input priority filter; policy drop;
...
table inet other {
type filter hook input priority filter + 10; policy accept;
$ probe c1 192.168.60.1 22
c1 -> 192.168.60.1 port 22 no answer (timeout)
$ nft list chain inet other input | grep counter
tcp dport 22 counter packets 3 bytes 180 drop comment "second chain says no"
The ssh rule in inet filter said accept, and the packet was dropped by the other table all the same. The counter shows who did it: 3 packets. With priority -10 the other table is consulted FIRST, and the result for this packet is the same, a drop, but the table order matters as soon as the rules in it do more than drop (marks, logging, rate limits). The practical rules:
Only the lowest-priority-number chain gets the first say, but a later chain can still drop. An accept is never final across chains.
A "policy accept" in a second chain does not undo a drop in the first, and does not override anything.
To see everything that hangs on a hook, nft list hooks shows the chains in order, with the kernel's own parts (connection tracking and so on) in between:
$ nft list hooks | head -9
family ip {
hook prerouting {
-0000000400 ipv4_conntrack_defrag [nf_defrag_ipv4]
-0000000200 ipv4_conntrack_in [nf_conntrack]
}
hook input {
0000000000 chain inet filter input [nf_tables]
+2147483647 nf_confirm [nf_conntrack]
}
Priorities can be written with a name and an offset (priority filter - 5; priority security); both appear as such in the listing.
When a rule you wrote "does not work", ask first whether another table is on the same hook.
Routing and NAT: masquerade, snat, dnat, redirect
A router needs the kernel to forward (sysctl net.ipv4.ip_forward=1, and for IPv6 net.ipv6.conf.all.forwarding=1; the lab's was already on), a forward chain that lets traffic through, and, between a private LAN and a public network, NAT. NAT lives in tables with chains of type nat. The file used in the lab, /root/router.nft:
flush ruleset
table inet filter {
chain input {
type filter hook input priority filter; policy drop;
ct state established,related accept
iif lo accept
tcp dport 22 accept
}
chain forward {
type filter hook forward priority filter; policy drop;
ct state established,related accept
iifname "eth1" oifname "eth2" accept comment "LAN to outside"
ct status dnat accept comment "anything that was port-forwarded"
}
}
table ip nat {
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
}
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
}
}
Forwarding is allowed from the LAN card to the outside card, plus anything that has been port-forwarded (ct status dnat: a nice way to say "connections whose destination was rewritten"); everything else in forward is dropped by the policy. With forwarding on and no NAT, a LAN client reaches the outside server only if that server knows a way back, which it does not here:
$ probe c1 198.51.100.50 9000
c1 -> 198.51.100.50 port 9000 no answer (timeout)
MASQUERADE rewrites the source of everything leaving through a card to that card's own address. It belongs in the postrouting chain:
$ nft add rule ip nat postrouting oifname \"eth2\" masquerade
$ probe c1 198.51.100.50 9000
c1 -> 198.51.100.50 port 9000 open: outside: you are 198.51.100.1
$ conntrack -L -p tcp | grep dport=9000
tcp 6 119 TIME_WAIT src=192.168.60.20 dst=198.51.100.50 sport=45904 dport=9000 src=198.51.100.50 dst=198.51.100.1 sport=9000 dport=45904 [ASSURED] mark=0 use=1
The outside server sees the firewall, 198.51.100.1, not the client. The conntrack entry has two halves: the original direction (the LAN client to the server) and the reply direction, addressed to the rewritten address. SNAT is the same with an address you choose, right for a fixed public address (MASQUERADE for one that changes):
$ nft flush chain ip nat postrouting
$ nft add rule ip nat postrouting oifname \"eth2\" ip saddr 192.168.60.0/24 snat to 198.51.100.1
$ probe c1 198.51.100.50 9000
c1 -> 198.51.100.50 port 9000 open: outside: you are 198.51.100.1
DNAT is port forwarding. In prerouting, rewrite the destination of what comes in on the outside card. The LAN web server (port 8000) is published on the firewall's port 8443:
$ probe c2 198.51.100.1 8443
c2 -> 198.51.100.1 port 8443 no answer (timeout)
$ nft add rule ip nat prerouting iifname \"eth2\" tcp dport 8443 dnat to 192.168.60.20:8000
$ probe c2 198.51.100.1 8443
c2 -> 198.51.100.1 port 8443 open: lan web: you are 198.51.100.50
It worked because the forward chain already has "ct status dnat accept": NAT rewrites the packet, but the filter still decides whether it may pass. The LAN host saw the true client address (198.51.100.50), since the way back runs through the firewall. A MAP puts many forwards in one rule: the key is the incoming port, the value is the address AND port to send it to (a concatenation):
$ nft add rule ip nat prerouting iifname \"eth2\" dnat to tcp dport map { 8443 : 192.168.60.20 . 8000, 8444 : 192.168.60.20 . 22 }
$ nft list chain ip nat prerouting
iifname "eth2" dnat ip to tcp dport map { 8443 : 192.168.60.20 . 8000, 8444 : 192.168.60.20 . 22 }
$ probe c2 198.51.100.1 8443 8444
c2 -> 198.51.100.1 port 8443 open: lan web: you are 198.51.100.50
c2 -> 198.51.100.1 port 8444 Connection refused
Port 8444 is "refused" by the LAN host itself (nothing listens on its port 22 in the lab): the packet DID get through the firewall and reached the host, which is the reply being shown. REDIRECT sends traffic to a port on the firewall itself:
$ nft add rule ip nat prerouting iifname \"eth1\" tcp dport 80 redirect to :22
$ probe c1 192.168.60.1 80
c1 -> 192.168.60.1 port 80 open: port 22: you are 192.168.60.20
Unlike the iptables case, the lab's input chain already allowed port 22, so the redirected packet got in. Remember that the filter chains see the packet AFTER the prerouting NAT, with the new port, as in iptables. Finally, since kernel 5.2 a nat chain may live in an inet table, which makes one NAT rule work for IPv4 and IPv6 together (the lab accepted a masquerade rule in table inet nat6 with type nat hook postrouting; address-family-specific rules such as dnat to a v4 address still need ip or ip6 in the rule).
Counters, logging, tracing and JSON
Counters are not automatic. Put counter into a rule to get one, or make a NAMED counter, shared and listed on its own:
$ nft add counter inet filter webhits
$ nft insert rule inet filter input tcp dport 80 counter name webhits
$ probe c1 192.168.60.1 80 > /dev/null ; nft list counters
table inet filter {
counter webhits {
packets 4 bytes 216
}
}
$ nft reset counter inet filter webhits # prints the OLD value, then zeroes it
table inet filter {
counter webhits {
packets 4 bytes 216
}
}
$ nft list counter inet filter webhits
counter webhits {
packets 0 bytes 0
}
Quotas (a byte limit per rule) and named limits exist in the same way (not run in the lab). The option -s (stateless) prints a ruleset WITHOUT the counter values, which is what you want when you keep it in version control: nft -s list ruleset > rules.nft gives a file that does not change every time a packet arrives.
Logging has two ways. The log statement writes to the kernel log; put a limit on it, or a scan fills the disk:
nft add rule inet filter input tcp dport 3307 log prefix \"NFT-DROP: \" level warn limit rate 5/minute drop
Those lines appear in journalctl -k and dmesg (not in the lab: inside a container's own network namespace the kernel does not log, dmesg counted 0 lines; a real server logs normally). The other is log group N, which sends the packet to a user-space listener (ulogd, or tcpdump), and that works everywhere:
$ nft insert rule inet filter input tcp dport 3306 log group 5 prefix \"DROPPED \" counter drop
$ tcpdump -i nflog:5 -n -l -c 1
19:36:45.455377 IP 192.168.60.20.46272 > 192.168.60.1.3306: Flags [S], seq 638825991, win 64240, options [mss 1460,sackOK,TS val 1468499874 ...
1 packet captured
TRACING shows every rule a packet meets, which is the answer to "why was this dropped" on a big ruleset. Tracing is switched on per packet with meta nftrace set 1, in a chain that runs BEFORE everything else (a prerouting chain at priority -301, ahead of connection tracking), and read with nft monitor trace. Put the trace rule at the END of your input chain and nothing prints, because the packet was accepted earlier; the lab did exactly that first:
table inet tracer {
chain pre {
type filter hook prerouting priority -301; policy accept;
tcp dport 80 ip saddr 192.168.60.20 meta nftrace set 1
}
}
$ nft monitor trace
inet tracer pre packet: iif "eth1" ether saddr 1a:2a:eb:9c:54:a5 ether daddr a6:2c:f9:b9:79:bc ip saddr 192.168.60.20 ip daddr 192.168.60.1 ...
inet tracer pre rule tcp dport 80 ip saddr 192.168.60.20 meta nftrace set 1 (verdict continue)
inet tracer pre policy accept
inet filter input packet: iif "eth1" ether saddr 1a:2a:eb:9c:54:a5 ether daddr a6:2c:f9:b9:79:bc ip saddr 192.168.60.20 ip daddr 192.168.60.1 ...
inet filter input rule tcp dport { 80, 443 } accept comment "web" (verdict accept)
Each block is one chain the packet entered: the packet's own fields, the rule that decided, and the verdict. Delete the tracer table (nft delete table inet tracer) when you are done, because a trace rule left in place is a constant stream of output.
JSON output (nft -j) turns the ruleset into data for scripts. Combined with jq, no scraping of text is needed:
$ nft -j list set inet filter admins | jq -c '.nftables[] | select(.set) | .set | {name,type,elem}'
{"name":"admins","type":"ipv4_addr","elem":[{"prefix":{"addr":"10.0.0.0","len":8}},"192.168.60.20"]}
$ nft -j list chain inet filter input | jq -r '.nftables[] | select(.rule) | .rule | "\(.handle) \(.expr | length) expressions"' | head -3
16 4 expressions
5 2 expressions
6 2 expressions
The same JSON can be fed back to nft -j -f FILE to load a ruleset, which is how larger programs (and some orchestration tools) drive nftables. (Loading JSON: not run in the lab.)
Making it permanent: /etc/nftables.conf and include
Everything above lives in the kernel and is gone at reboot. The distribution's nftables service reads /etc/nftables.conf at boot (systemctl enable --now nftables on Debian, Ubuntu and the Red Hat family; not run in the lab, which had no systemd). So the file you write and test IS the permanent configuration. You can capture a running ruleset into one:
$ nft list ruleset > /root/dump.nft
$ nft -c -f /root/dump.nft ; echo "round trip check rc=$?"
round trip check rc=0
$ head -3 /root/dump.nft
table inet filter {
set admins {
type ipv4_addr
Add flush ruleset at the top (see the warning above about that line) to make the dump a file that REPLACES instead of merging, and keep the file in version control. For a configuration that gets long, split it with include, and share the variables with define:
# /etc/nftables.d/sets.nft
define ADMINS = { 192.168.60.20, 192.168.60.21 }
# /etc/nftables.d/web.nft
add rule inet filter input tcp dport { 80, 443 } accept comment "web"
# /root/main.nft
flush ruleset
include "/etc/nftables.d/sets.nft"
table inet filter {
chain input {
type filter hook input priority filter; policy drop;
ct state established,related accept
iif lo accept
ip saddr $ADMINS tcp dport 22 accept
}
}
include "/etc/nftables.d/web.nft"
$ nft -c -f /root/main.nft && nft -f /root/main.nft && nft list chain inet filter input
table inet filter {
chain input {
type filter hook input priority filter; policy drop;
ct state established,related accept
iif "lo" accept
ip saddr { 192.168.60.20, 192.168.60.21 } tcp dport 22 accept
tcp dport { 80, 443 } accept comment "web"
}
}
The include files are read in place, so the order in the main file is the order of loading: the web rule had to come after the table it adds to. (Wildcards in include, like include "/etc/nftables.d/*.nft", are supported; not run in the lab.)
Changing a firewall without locking yourself out
iptables has iptables-apply; nftables has no such tool, but its atomic loading makes two habits cheap:
nft -c -f FILE before every load. A syntax error never reaches the kernel anyway (see the failed load above), but -c also checks what you meant.
Save the working ruleset BEFORE the change (nft list ruleset > good.nft), so there is something to go back to.
Test from a second connection before closing the first, and keep a console in reach.
A rollback with a timer is easy to build, and the lab tested one. The script loads a new file, then restores the old rules after N seconds unless you confirm by creating a file:
#!/bin/bash
# safe-nft NEWFILE [SECONDS]
new=$1; wait=${2:-30}
nft -c -f "$new" || exit 1
nft list ruleset > /run/safe-nft.good
rm -f /run/safe-nft.ok
nft -f "$new" && echo "applied $new; touch /run/safe-nft.ok within ${wait}s to keep it"
( sleep "$wait"; if [ -e /run/safe-nft.ok ]; then echo kept
else (echo "flush ruleset"; cat /run/safe-nft.good) > /run/safe-nft.restore; nft -f /run/safe-nft.restore; echo "ROLLED BACK"; fi ) > /run/safe-nft.log 2>&1 &
Applying a file that blocks everything, with nobody confirming:
$ safe-nft /root/lockout.nft 5
applied /root/lockout.nft; touch /run/safe-nft.ok within 5s to keep it
$ probe c1 192.168.60.1 22
c1 -> 192.168.60.1 port 22 no answer (timeout)
(five seconds later)
$ cat /run/safe-nft.log ; probe c1 192.168.60.1 22 80
ROLLED BACK
c1 -> 192.168.60.1 port 22 open: port 22: you are 192.168.60.20
c1 -> 192.168.60.1 port 80 open: port 80: you are 192.168.60.20
It is the same idea as iptables-apply, built from the pieces: a check, a saved copy, a timer, and a rule that ONLY a person who can still connect can cancel. Use 60 seconds, not 5, on a real link. An "at" job with the same restore command works too (at now + 5 minutes; not run in the lab). On a remote machine with no console, this is the difference between a typo and a trip to the data center.
Living next to iptables, firewalld and Docker
iptables-nft puts its rules in nftables tables of its own (in the lab, "table ip filter" appeared after one iptables command). They show up in nft list tables and in nft list ruleset, with a warning in front of them: "table ip filter is managed by iptables-nft, do not touch!". Look, do not edit: use iptables for those tables and nft for yours. Because their chains hang on the same hooks, everything from the priorities section applies: an accept in yours does not beat a drop in theirs.
firewalld (1.0 and later) uses nftables underneath and owns its tables (inet firewalld). Do not flush them; put your own rules in firewalld's configuration, or in a table with a priority that does not collide. (Not run in the lab.)
ufw still drives iptables. Docker and Podman add their own iptables (or nft) rules: read what they added before you flush anything. (Not run in the lab.)
fail2ban can use nftables actions that add addresses to a set in a table of its own; that is a good fit for the timeout sets above. (Not run in the lab.)
The rule of thumb is one manager per machine, and "flush ruleset" only on a machine where you ARE that manager.
Migrating an existing iptables setup: iptables-translate and iptables-restore-translate turn rules and whole files into nft syntax, one rule at a time (see the iptables page), and nft list ruleset on a machine running iptables-nft shows the equivalent structure. Read the result as a draft, and rewrite repeated rules as sets: ten ACCEPT lines for ten ports become one set; a list of blocked networks becomes one named set with timeout flags.
Cheat sheet
nft list ruleset everything (add -a for handles, -s without counters, -n numeric, -j JSON)
nft list tables / chains / sets / counters / hooks
nft list table inet filter / chain inet filter input / set inet filter NAME
nft -f FILE / nft -c -f FILE load atomically / check only
nft add table inet NAME a table (add: quiet if it exists; create: error)
nft add chain inet T C { type filter hook input priority 0 \; policy drop \; } a base chain
nft add chain inet T C a regular chain (for jump / goto)
nft add rule inet T C EXPR VERDICT append (insert rule: at the top; position HANDLE: after that handle)
nft -a list chain inet T C see handles
nft delete rule inet T C handle N delete (replace rule ... handle N RULE to replace)
nft flush chain inet T C / nft delete chain inet T C / nft delete table inet T / nft flush ruleset (everything, every table!)
nft add set inet T S { type ipv4_addr \; flags interval,timeout \; }
nft add element inet T S { 1.2.3.4 timeout 1h } / delete element ... / flush set inet T S
nft add map inet T M { type inet_service : ipv4_addr . inet_service \; }
tcp dport { 22, 80-90 } ip saddr @NAME ip saddr . tcp dport @NAME concatenation
tcp dport vmap { 22 : accept, 80 : jump web }
ct state established,related accept ct state new ct status dnat
iif lo iifname "eth1" oifname "eth2" meta l4proto { icmp, ipv6-icmp } meta skuid UID
limit rate 5/second burst 10 packets add @METER { ip saddr limit rate over 3/minute } add @CONNS { ip saddr ct count over 2 }
counter counter name NAME log prefix "X: " log group 5 reject with tcp reset drop accept
masquerade snat to ADDR dnat to ADDR:PORT redirect to :PORT
meta nftrace set 1 + nft monitor trace
nft describe ct state what values a field takes
Common problems
My rule does nothing another table's chain on the same hook drops (or accepts first): nft list hooks, then nft list ruleset. An accept does not end the journey across chains
Rules disappeared (Docker, firewalld, fail2ban stopped working) a file that began with flush ruleset wiped every table in the machine. Reload their service, and remove the line or use a table of your own
I replaced or deleted the wrong rule handles are not line numbers. Always nft -a list first. The lab replaced the 8080 block when it meant the 3306 rule
position 3 put the rule somewhere unexpected position means "after handle 3". Use handles, or write the file and load it
Error: No such file or directory the table or chain you named does not exist (typo in the family, table or chain); the message may say "did you mean ...". nft list tables shows what is there
Error: Could not process rule: File exists the object exists already (create), or an element overlaps in an interval set
Error: Could not process rule: Device or resource busy a chain still has a jump or a map pointing at it: remove the reference first
Error: conflicting intervals specified overlapping networks or ranges in an interval set: merge them (or enable auto-merge flag in the set, not run in the lab)
Error: datatype mismatch, expected IPv6 address ... the set or field has the wrong type: ip versus ip6, or an address where a port is expected
Error: Could not resolve hostname not a valid address: nft tried a DNS lookup. Quote real addresses; check for 999 or a stray character
"Operation not permitted (you must be root)" use sudo
A set element is gone after a while the set has the timeout flag (or a default timeout); that is the feature. Check nft list set for "expires"
The meter does not limit the set needs flags dynamic (and a size), the rule must be before the accept that would end it, and the clients must be NEW connections (ct state new)
Everything is open after a reboot the ruleset was never saved to /etc/nftables.conf, or the nftables service is not enabled (systemctl enable nftables; not run in the lab)
Log lines missing the log statement writes to the kernel log: journalctl -k. Inside containers and namespaces use log group N and tcpdump -i nflog:N
nft monitor trace prints nothing the nftrace rule never saw the packet: put it in a prerouting chain at priority -301, before the accept
IPv6 not working after hardening ICMPv6 was dropped: allow icmpv6 (neighbor discovery) in input, and in forward on a router
NAT rule matches but the connection hangs forward does not allow the translated packet (ct status dnat accept), or the reply does not come back through the firewall
I hand-edited and now nft -c fails at a line that looks right the error is often in the line BEFORE (a missing semicolon or brace); the caret points at where nft gave up, not where the mistake started
Tips
Write the ruleset as a file, check it with nft -c -f, load it with nft -f. It is atomic, readable, diffable, and a failed load changes nothing.
Use the inet family for filtering: one table, both protocols, no second rule set to forget.
Use sets and maps instead of lists of rules. A hundred addresses in a set is one rule and one lookup, and you change it live.
Use timeout sets for anything that should expire: temporary blocks, a grant of access for an hour.
Look at handles (nft -a) before deleting or replacing. Better: edit the file and reload.
Know what hangs on each hook (nft list hooks) before you blame a rule. Priorities decide the order, and an accept ends only its own chain.
Never begin a file with flush ruleset on a machine where something else (Docker, firewalld, fail2ban, libvirt) also uses nftables. Replace your own table instead.
Put your tables in their own names (inet mycorp) so tools can coexist with you.
Save a copy of the good ruleset before every change and have a timer-based rollback ready. Test from a second session.
Use nft -s list ruleset for a version-control friendly dump, nft -j with jq for scripts, and nft describe to find out what values a field accepts.
Read your own ruleset aloud. If you can say it as a sentence ("accept ssh from the admins, accept web from anyone, drop everything else"), it is probably right.