nftables is the firewall that finally makes Linux packet filtering feel like one
thing instead of four. If you have ever maintained an iptables ruleset, you know
the ritual: iptables for IPv4, ip6tables for IPv6, legacy on one distro and
iptables-nft on another, ipset for any list worth having, and a quiet dread
every time you edit a live chain. Converting iptables to nftables is not just a
syntax change — it replaces four tools with a single nft binary and turns the
firewall into something you can read top to bottom. I have migrated every box I
run, from a home router to public VPSes, and this is the walkthrough I wish someone
had handed me.
The mental model: tables → chains → rules
There are exactly three nouns in nftables, and once they click the rest is syntax.
- Table — a namespace that groups chains. Every table has a family:
ip,ip6,inet,arp,bridge, ornetdev. The one you almost always want isinet, which holds IPv4 and IPv6 rules together. That single choice deletes the entireiptables/ip6tablesduplication. - Chain — an ordered list of rules. A chain attached to a hook sees real
traffic; a regular chain is just a subroutine you
jumpto. A hooked chain needs three things: atype(filter,nat,route), ahook(prerouting,input,forward,output,postrouting) and apriority. - Rule — a match plus a statement.
tcp dport 443 acceptis a rule. Rules run top to bottom inside their chain, first match wins, andpolicydecides what happens if nothing matched.
Priority is the part people trip on. The kernel runs chains in ascending
priority order at each hook, across all tables. A nat chain at priority dstnat (-100) therefore runs before a filter chain at priority filter (0)
on the same hook. You rarely type numbers — the named values exist so you never
have to.
prerouting before the routing decision, input or forward after it, and postrouting on the way out.From iptables to nftables: what actually changed
- One tool.
nftreplacesiptables,ip6tables,arptables,ebtablesandipset. - One address-family view.
table inetmatches IPv4 and IPv6 in the same rule, so you stop maintaining two parallel rulesets. - You define the chains. There is no fixed
INPUT/OUTPUT/FORWARDto append to. You create a chain and hook it where you want it. - Sets are native. An IP list is a first-class object with fast membership
lookups — no separate
ipsettool, no generating a chain per address. - Atomic loads.
nft -f ruleset.nftswaps the entire ruleset in one transaction. If any line fails to parse, nothing is applied. - A real config file. The ruleset is text you keep in version control, not the
accumulated residue of a hundred
iptables -Acommands.
# the old world
iptables -A INPUT -p tcp --dport 22 -j ACCEPT
ip6tables -A INPUT -p tcp --dport 22 -j ACCEPT
iptables -A INPUT -j DROP
# the new world: one table, one chain, both families
nft add table inet filter
nft add chain inet filter input '{ type filter hook input priority filter; policy drop; }'
nft add rule inet filter input tcp dport 22 accept
In practice I do not type nft add interactively. I write the whole rule set in a
file and load it — which is the next section.
A real nftables ruleset for a server
This is the ruleset I put on a public box: default deny on input, allow loopback, allow established, drop invalid, allow SSH and HTTP(S), and drop everything else with a counter so I can watch the noise.
#!/usr/sbin/nft -f
# /etc/nftables.conf — default-deny firewall for a public server
flush ruleset
table inet filter {
chain input {
type filter hook input priority filter; policy drop;
# 1. loopback always talks to itself
iifname "lo" accept
# 2. replies to connections we opened, plus ICMP errors
ct state established,related accept
ct state invalid drop
# 3. the ICMP we actually want (v4 and v6 in one place)
ip protocol icmp icmp type { echo-request, destination-unreachable, time-exceeded } accept
ip6 nexthdr icmpv6 icmpv6 type { echo-request, destination-unreachable, packet-too-big, time-exceeded, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } accept
# 4. SSH: allow, but throttle floods of new connections per source
tcp dport 22 ct state new limit rate over 8/minute burst 4 packets
tcp dport 22 accept
# 5. the web
tcp dport { 80, 443 } accept
# 6. everything left is dropped, and counted
counter drop
}
chain forward {
type filter hook forward priority filter; policy drop;
}
chain output {
type filter hook output priority filter; policy accept;
}
}
A few things worth calling out:
flush rulesetmakes the file self-contained: loading it replaces whatever else was there, so the file is the whole truth.- Order matters.
ct state established,related acceptmust come before the default drop, or replies to your own outbound SSH die. limit rate over 8/minuteonly bites when one source opens more new connections than the burst allows; normal logins never notice it.policy droponforwardmatters the moment the box routes — a router, or a host running VMs behind a bridge. That is where a segmentation policy lives; if you are dividing a network, see home network segmentation.- If Docker is present, its published ports write their own rules. Keep service ports on loopback and reverse-proxy them. My VPS hardening checklist covers that plus the rest of the pre-flight work — SSH keys, fail2ban, unattended upgrades.
Managing IP lists with sets and named intervals
Sets are where nftables pulls decisively ahead of iptables. A set is a named collection of values that a rule can match against in one step.
table inet filter {
# a named set of exact addresses
set admin_hosts {
type ipv4_addr
flags interval
elements = { 198.51.100.7, 203.0.113.0/24 }
}
# a named interval set: ranges and CIDRs, auto-merged
set bogons {
type ipv4_addr
flags interval
auto-merge
elements = {
0.0.0.0/8, 10.0.0.0/8, 127.0.0.0/8, 169.254.0.0/16,
172.16.0.0/12, 192.168.0.0/16, 224.0.0.0/4
}
}
chain input {
type filter hook input priority filter; policy drop;
# ...
ip saddr @bogons drop
tcp dport 22 ip saddr @admin_hosts accept
# ...
}
}
Update a live set without touching the rest of the firewall:
# add one address to a running set — applies instantly
nft add element inet filter admin_hosts { 203.0.113.55 }
# remove it
nft delete element inet filter admin_hosts { 203.0.113.55 }
# inspect what a set holds
nft list set inet filter admin_hosts
Because a match is a set lookup rather than a chain of comparisons, a set holding
ten thousand addresses costs roughly the same per packet as one holding ten. That
is the difference between a firewall you can grow and one you keep apologising
for. The flags interval plus auto-merge combination is what lets you write
10.0.0.0-10.0.0.255 next to overlapping CIDRs and have nftables collapse them
into a minimal interval set on its own.
NAT: masquerade, source NAT, and port forwarding
nftables NAT is just a normal chain with type nat. Put source NAT in a
postrouting chain; masquerade rewrites the source address to whatever the
outgoing interface’s address is, which is exactly what a dynamic WAN wants.
table inet nat {
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
oifname "eth0" masquerade
}
}
On a static link, prefer explicit SNAT so nothing depends on the interface
address changing — oifname "eth0" snat to 203.0.113.10.
Port forwarding is destination NAT in prerouting:
table inet nat {
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
# public :8080 -> internal web server
iifname "eth0" tcp dport 8080 dnat to 10.0.0.5:80
# public :2222 -> a lab box's SSH
iifname "eth0" tcp dport 2222 dnat to 10.0.0.9:22
}
}
Two things people forget. First, forwarding needs net.ipv4.ip_forward=1 and a
matching accept in the forward chain — masquerade does not open the forward
path by itself.
sudo sysctl -w net.ipv4.ip_forward=1
echo 'net.ipv4.ip_forward=1' | sudo tee /etc/sysctl.d/99-forward.conf
chain forward {
type filter hook forward priority filter; policy drop;
iifname "eth0" oifname "lan0" ct state established,related accept
iifname "eth0" oifname "lan0" tcp dport { 22, 80 } accept
}
Second, the DNAT happens in prerouting, before the routing decision, so the
forward filter chain sees the packet already destined for the internal host.
Persistence: keeping your firewall across reboots
A ruleset lives in the kernel and dies with the machine. The fix is a file plus a unit.
# /etc/nftables.conf is loaded by the nftables.service unit at boot
sudo nft --check -f /etc/nftables.conf # syntax check, apply nothing
sudo nft -f /etc/nftables.conf # apply now (atomic)
# Debian / Ubuntu
sudo systemctl enable --now nftables
systemctl status nftables --no-pager
# capture what is actually running back into the file
sudo sh -c 'nft list ruleset > /etc/nftables.conf'
On the RHEL/Fedora family the unit is also nftables.service, but it loads
/etc/sysconfig/nftables.conf, which includes your file. Whichever distro, the
model is the same: the file is the source of truth, the unit replays it at boot,
and because nft -f is atomic, a broken file never leaves you half-configured.
Reading the ruleset with nft list ruleset
Half the value of one tool is one view.
nft list ruleset # every table, both address families
nft list table inet filter # just the filter table
nft -a list ruleset # annotate each rule with its handle
nft list sets # your IP lists and their elements
nft monitor # live tail of ruleset changes
Handles are how you delete a single rule — nft delete rule inet filter input handle 27 — and nft list ruleset emits a file that is itself a valid ruleset,
which is why the persistence round-trip above works.
Don’t lock yourself out
There is one rule for remote firewalls: never be the only reason you cannot get back in. Concretely: allow your SSH port before you ever set a default-drop policy, keep a second session open, and know how to reach the provider’s console.
# 1. save what is running NOW, before changing anything
sudo nft list ruleset > /root/nftables.rollback.nft
# 2. arm an automatic rollback: if you are not back in 5 minutes, revert
sudo sh -c 'sleep 300; nft -f /root/nftables.rollback.nft' &
# 3. apply the new ruleset (a syntax error here changes nothing)
sudo nft -f /etc/nftables.conf
# 4. log in from a NEW terminal and confirm access
ssh -p 22 deploy@203.0.113.10 'echo still here'
# then cancel the timer
sudo pkill -f 'sleep 300'
If step 4 fails, do nothing: five minutes later the old ruleset is back and your
session recovers. Validate first with nft --check -f and this habit will cost you
nothing and save you a console trip.
The short version
iptables to nftables is mostly subtraction. Four tools become one; two
rulesets (IPv4 and IPv6) become one inet table; the ipset dance disappears into
native sets. The mental model is three words — tables, chains, rules — plus hooks
and priorities, and then the whole firewall is a file you can read. Write a
default-deny input with policy drop; allow lo, established,related, the
ICMP you need, SSH and 80/443; drop the rest with a counter. Add NAT in a nat
chain — masquerade in postrouting, dnat in prerouting. Persist it as
/etc/nftables.conf behind the nftables.service unit, and before you load it on
a remote machine, arm a five-minute rollback and verify from a second session. Do
it once and iptables starts to look like the painful version.