Stateful filtering in nftables means you accept replies to connections you already allowed, instead of opening a fixed range of ports in both directions. You do that with the ct state expression, which reads the kernel's connection tracking table, and you keep the ruleset readable by chaining rules into named chains. This guide builds a complete input filter on a Linux VPS: connection tracking first, then jumps into per-protocol chains, then verdict maps for fast lookups.
Before you start
- A Linux VPS running Ubuntu 22.04/24.04, Debian 12, AlmaLinux 8–9 or Rocky 8–9, with root or
sudo. - nftables installed and the
nftbinary available. Check withnft -v. - Your SSH port number, plus a second SSH session already open or the browser console in your VPS provider's control panel ready. A wrong
policy droprule will cut the first session. - A backup of the current ruleset:
sudo nft list ruleset > /root/nftables-backup-$(date +%F).nftWarning: Loading a ruleset with policy drop before you allow established traffic and SSH will drop your connection immediately. Keep the console open until the ruleset is verified.
Understand stateful filtering with ct state
Connection tracking assigns every packet one of five states: new, established, related, invalid and untracked. The first two rules below are what make a firewall stateful.
sudo nft add table inet filter
sudo nft add chain inet filter input '{ type filter hook input priority filter; policy drop; }'
sudo nft add rule inet filter input ct state established,related accept
sudo nft add rule inet filter input ct state invalid drop
sudo nft add rule inet filter input iif lo acceptestablished,related accepts return traffic for connections the firewall already permitted, so you never need to open high ports for replies. invalid drops packets conntrack cannot classify, which is usually malformed traffic or a state mismatch. related covers ICMP errors and helper-driven flows such as FTP data channels.
Tip: ct state only works when the nf_conntrack module is loaded. On most kernels it loads automatically the first time you use the expression.
Chain rules into named chains
A single input chain becomes unreadable after twenty rules. Named chains (regular chains with no hook) let you split by protocol and jump in and out. jump returns to the caller when the named chain ends; goto does not return, which is useful for terminal branches.
sudo nft add chain inet filter tcp_in
sudo nft add chain inet filter udp_in
sudo nft add chain inet filter icmp_in
sudo nft add rule inet filter input ip protocol icmp jump icmp_in
sudo nft add rule inet filter input ip protocol tcp jump tcp_in
sudo nft add rule inet filter input ip protocol udp jump udp_inNow fill the branches. A rule in a named chain that does not match falls through to the next one, and the packet returns to the input chain when the chain ends without a verdict.
sudo nft add rule inet filter icmp_in icmp type echo-request limit rate 10/second accept
sudo nft add rule inet filter icmp_in icmpv6 type '{ echo-request, nd-neighbor-solicit, nd-neighbor-advert, nd-router-advert }' accept
sudo nft add rule inet filter tcp_in tcp dport 22 ct state new limit rate 6/minute burst 4 packets accept
sudo nft add rule inet filter tcp_in tcp dport '{ 80, 443 }' ct state new accept
sudo nft add rule inet filter tcp_in tcp dport 22 ct state new log prefix "ssh-drop " dropThe limit statement is evaluated per rule, so the SSH rule allows a short burst and then drops the rest — cheap protection against brute force before you add Fail2ban. Anything reaching the end of tcp_in returns to input, where the chain policy drop applies.
Use verdict maps for fast chaining
When many ports map to different chains, a verdict map replaces a long list of comparisons with one lookup. The kernel resolves the map in a single step regardless of size.
sudo nft add map inet filter services '{ type inet_service : verdict; }'
sudo nft add element inet filter services '{ 22 : jump tcp_in, 80 : jump tcp_in, 443 : jump tcp_in, 53 : jump udp_in }'
sudo nft add rule inet filter input tcp dport vmap @services
sudo nft add rule inet filter input udp dport vmap @servicesMaps can also hold concatenations, for example type ipv4_addr. inet_service: verdict, which routes a specific source address and port to a specific chain. Keep the map small and rebuild it with nft -f rather than editing elements one by one in production.
Add connection tracking helpers and timeouts
Some protocols negotiate extra ports and need a conntrack helper. FTP is the classic case.
sudo nft add ct helper inet filter ftp-standard '{ type "ftp" protocol tcp; }'
sudo nft add rule inet filter tcp_in tcp dport 21 ct helper set "ftp-standard"Helper names must match the kernel module, and they differ between releases, so check nft describe ct helper and your distribution's documentation before relying on one. For long-lived idle sessions you can define a ct timeout policy and attach it with ct timeout set, but only when the application needs it — longer timeouts mean a larger conntrack table.
Persist the ruleset
Rules added with nft add live in memory only. Put the full ruleset in /etc/nftables.conf and load it as one file.
sudo nft list ruleset | sudo tee /etc/nftables.conf
sudo nft -c -f /etc/nftables.conf
sudo systemctl enable --now nftablesnft -c parses the file without applying it, so syntax errors are caught before they touch the live ruleset. On Debian and Ubuntu the nftables.service unit reads /etc/nftables.conf; on AlmaLinux and Rocky the same unit exists, but confirm the path with systemctl cat nftables for your build.
Check it works
- List the live ruleset with handles and counters:
sudo nft -a list ruleset- Watch packets hit your chains in real time from a second terminal:
sudo nft monitor trace- Confirm conntrack entries exist for your SSH session:
sudo conntrack -L | grep :22- From another host, test a blocked port and expect a timeout:
nc -vz 203.0.113.10 8080- Reboot and confirm the ruleset comes back:
sudo systemctl rebootTroubleshooting
- SSH stops responding right after applying the ruleset → the
ct state established,related acceptrule is missing or sits after the drop, or your SSH port is not intcp_in. Recover through the browser console in your VPS provider's control panel, then reorder the rules. Error: Could not process rule: Operation not permitted→ you are not root and lackCAP_NET_ADMIN. Run the command withsudo.- Rules disappear after a reboot → the ruleset was never written to
/etc/nftables.conf, ornftables.serviceis disabled. Runsystemctl enable --now nftablesand checksystemctl status nftables. ct statenever matches → the conntrack module is not loaded. Checklsmod | grep conntrackand load it withmodprobe nf_conntrack.- Duplicate rules pile up after each reload → you appended with
nft addinstead of loading a full file. Flush withsudo nft flush rulesetand load the file again, or usenft -fwith a complete table definition.
Next steps
- [Get started with n
