Sets and maps
Rules that list addresses or ports one per line get long and slow. nftables has two data structures that keep them short, and lets you change their contents without touching the rules.
Sets
A set is a named collection of values of one type (ipv4_addr, inet_service for ports, interface names…). The router's SSH rule matches ip saddr @admins, so adding an admin is one command, nft add element, and takes effect immediately; nothing is reloaded. In the lab, the web server couldn't reach SSH, then could after it was added to the set, and couldn't again once it was removed.
Changes made with commands live only in the kernel. The next systemctl reload nftables or reboot loads the file again, so put permanent changes in the file too.
Anonymous sets, written inline as { 80, 443 }, work the same way and are just as fast.
Dynamic sets: per-address rate limits
A set with flags dynamic can be filled by the rules themselves. The extra file web-flood.nft adds one that tracks every address opening new connections to the office web server, each with its own rate limiter: after a burst of 5, at most 10 new connections a minute per address. A client that goes over is dropped; everyone else is unaffected. Entries expire after a minute. This is the nftables way to slow down brute-force and flood attempts.
The file has no flush ruleset, so nft -f adds it to what's running. That's handy for experiments, but it's also why your main file should start with a flush.
Maps
A map looks a key up and returns a value. forwards.nft turns the port forwards into a map from the public port to the inside address and port, so a new forward is a new element, not a new rule. A verdict map (vmap) returns a verdict instead, for example iifname vmap { "eth0" : jump from_wan, "eth1" : jump from_lan } to send each interface's traffic to its own chain.
destroy table ip nat deletes that one table if it exists, and the rest of the ruleset (and any other program's tables) stays untouched.
root@srv:~# nc -zv -w 2 10.10.0.1 22
nc: connect to 10.10.0.1 port 22 (tcp) timed out: Operation now in progress
root@fw:~# nft add element inet filter admins { 10.10.0.80 } && nft list set inet filter admins
table inet filter {
set admins {
type ipv4_addr
elements = { 10.10.0.20, 10.10.0.80 }
}
}
root@srv:~# nc -zv -w 2 10.10.0.1 22
Connection to 10.10.0.1 22 port [tcp/ssh] succeeded!
root@fw:~# nft delete element inet filter admins { 10.10.0.80 } && nft list set inet filter admins | grep elements
elements = { 10.10.0.20 }- Lines 1–2: The web server isn't an admin.
- Lines 4–10: Add it to the set: one element, no reload.
- Lines 12–13: Allowed straight away.
- Lines 15–16: And removed again.
# /root/web-flood.nft: no "flush ruleset", so nft -f adds this to the running rules.
# At most 10 new connections a minute (after a burst of 5) from each address to the web server.
table inet filter {
set web_flood {
type ipv4_addr
flags dynamic
timeout 1m
}
}
insert rule inet filter forward iifname "eth0" ct state new ip daddr 10.10.0.80 tcp dport 80 update @web_flood { ip saddr limit rate over 10/minute burst 5 packets } counter drop comment "too many new connections"- Line 1: No flush: this adds to the running ruleset.
- Lines 4–8: A dynamic set: the rule below fills it, and entries expire after a minute.
- Line 10: For each new connection to the web server, look up (or create) the source's limiter; over the limit, drop.
root@fw:~# nft -f web-flood.nft && nft list chain inet filter forward | head -4
table inet filter {
chain forward {
type filter hook forward priority filter; policy drop;
iifname "eth0" ct state new ip daddr 10.10.0.80 tcp dport 80 update @web_flood { ip saddr limit rate over 10/minute burst 5 packets } counter packets 0 bytes 0 drop comment "too many new connections"
root@inet:~# for i in $(seq 1 8); do curl -s -o /dev/null -w "%{http_code} " -m 1 http://203.0.113.10:8080/; done; echo
200 200 200 200 200 000 000 000
root@fw:~# nft list set inet filter web_flood; nft list chain inet filter forward | grep "too many"
table inet filter {
set web_flood {
type ipv4_addr
size 65535
flags dynamic,timeout
timeout 1m
elements = { 203.0.113.50 limit rate over 10/minute burst 5 packets expires 58s892ms }
}
}
iifname "eth0" ct state new ip daddr 10.10.0.80 tcp dport 80 update @web_flood { ip saddr limit rate over 10/minute burst 5 packets } counter packets 3 bytes 180 drop comment "too many new connections"- Lines 1–5:
insertput the rule at the top of the forward chain. - Lines 7–8: Five connections (the burst), then dropped.
- Line 17: The internet host's entry, with its limiter and expiry time.
- Line 20: Three dropped connection attempts.
# /root/forwards.nft: replace only the nat table, now with the port forwards in a map.
# "destroy" deletes the table if it exists, and leaves every other table alone.
destroy table ip nat
table ip nat {
map forwards {
type inet_service : ipv4_addr . inet_service
elements = { 8080 : 10.10.0.80 . 80,
8000 : 10.10.0.80 . 8000 }
}
chain prerouting {
type nat hook prerouting priority dstnat;
iifname "eth0" dnat ip to tcp dport map @forwards
}
chain postrouting {
type nat hook postrouting priority srcnat;
oifname "eth0" masquerade
}
}- Line 3: Replace this one table, and nothing else.
- Lines 6–10: Public port → inside address and port (a concatenation, joined with a dot).
- Line 14: One rule for every forward.
root@fw:~# nft -c -f forwards.nft && nft -f forwards.nft && nft list tables
table inet filter
table ip nat
root@inet:~# curl -s -o /dev/null -w "%{http_code}\n" -m 2 http://203.0.113.10:8080/; curl -s -o /dev/null -w "%{http_code}\n" -m 2 http://203.0.113.10:8000/
200
200
root@srv:~# tail -1 /tmp/web.log; tail -1 /tmp/web8000.log
203.0.113.50 - - [21/Oct/2026 07:50:20] "GET / HTTP/1.1" 200 -
203.0.113.50 - - [21/Oct/2026 07:50:20] "GET / HTTP/1.1" 200 -- Lines 1–3: Only the nat table was replaced; the filter table is untouched.
- Lines 5–7: Both forwards work…
- Lines 9–11: …and each server sees the visitor's address.
Check your understanding
You add an address to @admins with nft add element. What happens at the next reboot?
- It's gone: the service loads /etc/nftables.conf, which doesn't have it.
- It's kept: sets are saved automatically.
- The set is emptied completely.
- The reboot fails because the file and kernel disagree.