TTR Networks

Intermediate · nftables and iptables on Debian: host and router firewalls · section 6 of 10

Sets and maps

✓ Lab-tested 21 October 2026 on Debian 13.7, nftables 1.1.3, iptables 1.8.11

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.

Changing a set live (real output)
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
# /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.
Limiting new connections per address (real output)
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: insert put 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: port forwards as a map
# /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.
Two forwards from one rule (real output)
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.