Many users, including some large projects, migrate to nftables by running their existing iptables rules through iptables-translate and leaving it at that. It works, sure, but it only changes the syntax, and the syntax was never the point. nftables can keep data in the ruleset, and rules can look things up in it and change it as needed, so a lot of what used to need a daemon/script next to the firewall can now be done by the firewall itself.
All examples were run on Linux 7.2 with nftables 1.1.7, so older versions may print stuff differently or not support every feature.
Sets
Sets group multiple elements under a single identifier, so rules can match against them without listing every element separately. So far that’s just ipset, but in nftables each element can have its own state, like a rate limit, a quota or when it last matched.
Give a set flags dynamic and rules can add elements to it using add @s { key }, remove them using delete @s { key }, or use update @s { key }, which works like add but also resets the timeout of an element that’s already there. The key can be a single field, a concatenation of several fields like ip saddr . tcp dport, or a masked value like ip saddr & 255.255.255.0, which turns a whole /24 into a single element (e.g., to ban the entire subnet at once).
Banning
Sets can be used to build something like Fail2ban directly in the firewall, except it looks at traffic instead of log files.
$ nft -s list table ip f2b
table ip f2b {
set jail {
typeof ip saddr
size 65535
flags dynamic,timeout
timeout 10m
}
set syn_rate {
typeof ip saddr
size 65535
flags dynamic,timeout
timeout 1m
}
chain input {
type filter hook input priority filter; policy accept;
ip saddr @jail counter drop
tcp dport 80 ct state new update @syn_rate { ip saddr limit rate over 10/second burst 5 packets } add @jail { ip saddr } counter drop
}
}
Each ip saddr gets its own rate limiter in @syn_rate. The moment one goes over, the same rule adds it to @jail, and the rule above drops its traffic until the timeout removes it from the set. nft shows how many elements the set holds and how long each one has left.
$ nft list set ip f2b jail
table ip f2b {
set jail {
typeof ip saddr
size 65535 # count 1
flags dynamic,timeout
timeout 10m
elements = { 10.0.0.2 expires 8m32s967ms }
}
}
Quotas
Each element can also have a quota, so every client gets its own data cap (iptables needed one rule per address). Here, every client can download 1 GiB a day from the web server on port 80.
$ nft -s list table ip cap
table ip cap {
set usage {
typeof ip daddr
size 65535
flags dynamic,timeout
timeout 1d
}
chain output {
type filter hook output priority filter; policy accept;
tcp sport 80 add @usage { ip daddr quota over 1024 mbytes } drop
}
}
Since add never resets the timeout of an element that’s already there, each one expires a day after it was added, and the client starts over with a fresh quota. nft shows how much of its quota each client has used.
$ nft list set ip cap usage
table ip cap {
set usage {
typeof ip daddr
size 65535 # count 1
flags dynamic,timeout
timeout 1d
elements = { 10.0.0.2 quota over 1024 mbytes used 108266055 bytes expires 23h56m41s865ms }
}
}
Tracking
last records when something last matched. If added to a rule, it makes nft show how long ago the rule last matched (or never if it hasn’t yet).
$ nft list table inet track
table inet track {
chain input {
type filter hook input priority filter; policy accept;
tcp dport 22 last used 5ms accept
tcp dport 80 last used never accept
}
}
If added to a set, it timestamps each element separately. Combined with update in an output chain, it gives a set that fills itself with every IPv4 TCP destination and port the host connects to, along with how long ago each one was last used and when it will expire. ct direction original leaves out replies to incoming connections, so the set doesn’t fill up with the ephemeral ports of every client.
$ nft list table ip track
table ip track {
set seen {
typeof ip daddr . tcp dport
size 65535 # count 1
flags dynamic,timeout
last
timeout 1h
elements = { 10.0.1.1 . 80 last used 51ms expires 59m59s949ms }
}
chain output {
type filter hook output priority filter; policy accept;
ct direction original update @seen { ip daddr . tcp dport }
}
}
Port knocking
Sets can also track progress, with one set per step. add/delete move a client to the next set, and the timeouts decide how long it has to get there.
$ nft -s list table ip knock
table ip knock {
set stage1 {
typeof ip saddr
size 65535
flags dynamic,timeout
timeout 5s
}
set stage2 {
typeof ip saddr
size 65535
flags dynamic,timeout
timeout 5s
}
set opened {
typeof ip saddr
size 65535
flags dynamic,timeout
timeout 30s
}
chain input {
type filter hook input priority filter; policy accept;
ct state established,related accept
tcp dport 7003 add @stage1 { ip saddr } drop
tcp dport 7001 ip saddr @stage1 add @stage2 { ip saddr } delete @stage1 { ip saddr } drop
tcp dport 7002 ip saddr @stage2 add @opened { ip saddr } delete @stage2 { ip saddr } drop
tcp dport 22 ip saddr @opened accept
tcp dport 22 drop
}
}
Knocking on TCP ports 7003, 7001 and 7002 in that order, each within 5 seconds of the last, opens port 22 for 30 seconds, for that source address only. The first rule lets SSH sessions started in that time keep working after the port closes again.
Per-service rules
Sets don’t have to be filled by rules. With NFTSet=, systemd puts the cgroup of a unit into a set when it starts and takes it out when it stops. socket cgroupv2 then matches packets by the cgroup of their socket.
$ systemctl cat app
# /etc/systemd/system/app.service
[Unit]
Description=Demo web app
[Service]
ExecStart=/usr/bin/python3 -m http.server 9000 --directory /srv/app
NFTSet=cgroup:inet:svc:allowed
systemd only adds/removes elements, so the table has to exist before the unit starts.
$ nft list table inet svc
table inet svc {
set allowed {
type cgroupsv2
elements = { "system.slice/app.service" }
}
chain input {
type filter hook input priority filter; policy accept;
ct state established,related accept
tcp dport 9000 socket cgroupv2 level 2 @allowed accept
tcp dport 9000 drop
}
}
Only app.service gets traffic on port 9000, even if something else listens there while it’s stopped. After the ruleset is reloaded, systemctl daemon-reload fills the set again.
Maps
Maps are like sets, but each element also has a value, so instead of only checking whether a key is there, a rule looks up its value and uses it, e.g., as a verdict or as data for another statement.
ACLs
A verdict map can replace a chain of N rules with one rule and one lookup. Large Kubernetes clusters ran into latency issues with kube-proxy’s iptables mode, where every new connection was checked against one rule per service, so its nftables mode uses one instead.
With flags interval, each part of a concatenated key can also be a prefix/range (ipset only had fixed combinations like hash:net,port), so a gateway between the clients in 10.0.0.0/24 and the servers in 10.0.1.0/24 can keep its whole ACL in one map.
$ nft -s list table ip fw
table ip fw {
map acl {
typeof ip saddr . ip daddr . tcp dport : verdict
flags interval
elements = { 10.0.0.0/24 . 10.0.1.1 . 80 : accept,
10.0.0.3 . 10.0.1.0/24 . 22 : jump log_ssh,
10.0.0.2 . 10.0.1.2 . 8000-8999 : accept }
}
chain log_ssh {
log prefix "ssh: " accept
}
chain forward {
type filter hook forward priority filter; policy drop;
ct state vmap { invalid : drop, established : accept, related : accept }
ip saddr . ip daddr . tcp dport vmap @acl
}
}
The ct state vmap rule replaces three separate rules for established, related and invalid packets, so only new connections get to the ACL. Every client can reach the web server on 10.0.1.1, 10.0.0.3 can SSH into every server (through log_ssh), and 10.0.0.2 can reach ports 8000 to 8999 on 10.0.1.2. Anything else (e.g., 10.0.0.2 to 10.0.1.2 port 22) has no element and ends up at the drop policy. nft get element shows which element covers a given connection.
$ nft get element ip fw acl '{ 10.0.0.2 . 10.0.1.2 . 8443 }'
table ip fw {
map acl {
type ipv4_addr . ipv4_addr . inet_service : verdict
flags interval
elements = { 10.0.0.2 . 10.0.1.2 . 8000-8999 : accept }
}
}
Port forwarding
Keys and values can both be concatenations, so a single dnat rule can forward any number of ports, each to its own address/port (iptables needed one rule per forwarded port).
$ nft -s list table ip nat
table ip nat {
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
dnat ip to ip daddr . tcp dport map { 10.9.9.9 . 2201 : 10.0.1.1 . 22, 10.9.9.9 . 2202 : 10.0.1.2 . 22 }
}
}
Port 2201 on the gateway (10.9.9.9) leads to SSH on 10.0.1.1, and port 2202 to SSH on 10.0.1.2.
Load balancing
Like sets, maps can be filled by rules, so a map can remember which backend each client got and keep sending it to the same one.
$ nft -s list table ip lb
table ip lb {
map sticky {
typeof ip saddr : ip daddr
size 65535
flags dynamic,timeout
timeout 30m
}
chain pick {
type nat hook prerouting priority dstnat; policy accept;
ip daddr 10.9.9.9 tcp dport 80 dnat to ip saddr map @sticky
ip daddr 10.9.9.9 tcp dport 80 dnat to numgen inc mod 2 map { 0 : 10.0.1.1, 1 : 10.0.1.2 }
}
chain learn {
type filter hook prerouting priority filter; policy accept;
ct direction original ct original ip daddr 10.9.9.9 update @sticky { ip saddr : ip daddr }
}
}
The first rule in pick looks up the client’s address in @sticky and, if it’s there, sends the connection to the stored backend. If not, the rule doesn’t match, and the second rule takes turns between the backends. numgen inc mod 2 counts 0, 1, 0, 1 and so on, and the map in the same rule turns that number into an address. Since nat rules only run on the first packet of a connection, this choice is made once per connection.
learn runs after pick (priority filter comes after dstnat), so ip daddr is already the chosen backend there, and update saves it for the client. ct direction original makes sure only packets from the client count, since replies are part of the same connection and would otherwise store each backend as a key pointing to the client.
Netdev hooks
Chains in netdev tables can attach to network devices instead of the IP stack, so they run before the IP stack sees a packet (ingress) and again right before it’s sent (egress). iptables had no hooks there.
Early drops
Ingress runs before conntrack and routing, so dropping a packet there is as cheap as it gets in nftables, which is handy for floods or blocklists.
$ nft -s list table netdev edge
table netdev edge {
set blocklist {
typeof ip saddr
flags interval
elements = { 10.66.0.0/16, 10.77.0.0/16 }
}
chain ingress {
type filter hook ingress device "wan0" priority filter; policy accept;
ip saddr @blocklist counter drop
}
}
Everything from the listed networks is dropped as soon as it arrives on wan0. An inet table can have an ingress chain too, so the same set can also be used in its input or forward chains.
Packet sockets
LLDP daemons and DHCP clients send their frames through packet sockets (AF_PACKET), which skip the IP stack, so the iptables OUTPUT chain and the nftables output hook never see them. An egress chain does, so here it stops LLDP from announcing the host on wan0.
$ nft list ruleset
table inet host {
chain output {
type filter hook output priority filter; policy accept;
oifname "wan0" counter packets 0 bytes 0
}
}
table netdev edge {
chain egress {
type filter hook egress device "wan0" priority filter; policy accept;
ether type 0x88cc counter packets 5 bytes 300 drop
}
}
Five LLDP frames were sent, and only the egress chain saw them.
Direct forwarding
fwd sends a packet straight from ingress out of another device, skipping routing, conntrack and the prerouting/forward/postrouting hooks. In the example, all traffic between the clients and the servers is forwarded like that, and the kernel still looks up the MAC address of the next hop.
$ nft -s list table netdev wire
table netdev wire {
chain from_clients {
type filter hook ingress device "lan0" priority filter; policy accept;
ip daddr 10.0.1.0/24 fwd ip to ip daddr device "dmz0"
}
chain from_servers {
type filter hook ingress device "dmz0" priority filter; policy accept;
ip daddr 10.0.0.0/24 fwd ip to ip daddr device "lan0"
}
}
This works with IP forwarding turned off too. dup to does the same but sends a copy, e.g., to an IDS.
Bridges
A bridge passes traffic between its ports without going through the IP stack, so iptables didn’t see it at all, unless br_netfilter was loaded to push it through the IP hooks (for every bridge on the host). The bridge family sees that traffic directly and has its own conntrack, so ct state works there too.
$ nft -s list table bridge vmhost
table bridge vmhost {
chain forward {
type filter hook forward priority filter; policy accept;
ct state established,related accept
oifname { "vm1", "vm2" } ct state new drop
}
}
Guests on vm1 and vm2 can connect anywhere, but nothing can connect to them.
Flowtables
Normally, every forwarded packet goes through prerouting, routing, forward and postrouting, even when its connection was already checked. A flowtable remembers established connections added with flow add, so their packets are caught at ingress and sent straight out the right device.
$ nft -s list table inet router
table inet router {
flowtable ft {
hook ingress priority filter
devices = { "lan0", "wan0" }
}
chain forward {
type filter hook forward priority filter; policy accept;
meta l4proto { tcp, udp } flow add @ft
}
}
With flags offload, NICs and switch chips that support it can handle the flows in hardware.
Conntrack timeouts
A ct timeout object holds custom timeouts for one protocol, and ct timeout set applies them to specific connections without touching the global sysctls (iptables needed the nfct tool). In this example, conntrack forgets DNS queries after 5 seconds instead of 30, so a busy DNS server doesn’t fill up the table.
$ nft -s list table ip dns
table ip dns {
ct timeout fast_udp {
protocol udp
l3proto ip
policy = { unreplied : 5s, replied : 5s }
}
chain input {
type filter hook input priority filter; policy accept;
udp dport 53 ct timeout set "fast_udp"
}
}
Multiple tables
In iptables, every tool adds its rules to the same few built-in chains. In nftables, each tool usually creates its own table, so one hook can have several chains, and a packet goes through all of them, ordered by priority. An accept only ends the chain it’s in, and the packet continues to the next one, but a drop stops it completely.
$ nft -s list ruleset
table inet mine {
chain input {
type filter hook input priority filter; policy accept;
tcp dport 80 accept
}
}
table inet theirs {
chain input {
type filter hook input priority filter + 10; policy accept;
tcp dport 80 drop
}
}
Port 80 stays closed, because theirs drops what mine accepted. nft list hooks shows all chains on a hook in the order they run.
$ nft list hooks ip
family ip {
hook input {
0000000000 chain inet mine input [nf_tables]
+0000000010 chain inet theirs input [nf_tables]
}
}
Optimizer
nft -o finds rules that can be merged and turns them into sets, concatenations and verdict maps, and -c makes it only show what it would change. It doesn’t work on the add rule commands that iptables-restore-translate writes, only on rulesets in the format nft list ruleset prints, so dump the loaded ruleset first.
$ nft -s list ruleset > ruleset.nft
$ nft -cof ruleset.nft
Merging:
ruleset.nft:5:3-47: ip saddr 10.0.0.2 tcp dport 22 counter accept
ruleset.nft:6:3-47: ip saddr 10.0.0.3 tcp dport 22 counter accept
into:
ip saddr . tcp dport { 10.0.0.2 . 22, 10.0.0.3 . 22 } counter accept
Merging:
ruleset.nft:7:3-27: tcp dport 25 counter drop
ruleset.nft:8:3-29: tcp dport 80 counter accept
ruleset.nft:9:3-30: tcp dport 443 counter accept
into:
tcp dport vmap { 25 counter : drop, 80 counter : accept, 443 counter : accept }
Five translated rules turn into one set lookup and one verdict map, and the map keeps a counter for each element.