iptables vs nftables on Ubuntu
On Ubuntu the iptables command already writes nftables rules. Prove it on your box, read the native ruleset, and see where ufw and Docker collide.
iptables vs nftables on Ubuntu: which one is your box running?
On Ubuntu 20.04 and later, the iptables command is a front end that writes nftables rules. One packet filter runs in the kernel, nftables, and two user space commands program it. An iptables -A INPUT line still works exactly as it did, and the rule it creates is an nftables rule that nft can print.
Confirm that on your own server before you believe it.
iptables -V
sudo update-alternatives --display iptables
sudo nft list rulesetOn Ubuntu 24.04 (iptables 1.8.10, as of August 2026), iptables -V prints iptables v1.8.10 (nf_tables). The name in brackets is the back end. (nf_tables) means the command talks to nftables. (legacy) means the old x_tables back end, which Ubuntu still ships as iptables-legacy and which the kernel keeps as a completely separate ruleset. update-alternatives prints the symlink behind that choice: link currently points to /usr/sbin/iptables-nft.
On a fresh VPS with no firewall configured, sudo nft list ruleset prints nothing. That empty output is your baseline. Add one rule the old way and look again.
sudo iptables -A INPUT -p tcp --dport 22 -j ACCEPT
sudo nft list ruleset# Warning: table ip filter is managed by iptables-nft, do not touch!
table ip filter {
chain INPUT {
type filter hook input priority filter; policy accept;
tcp dport 22 counter packets 0 bytes 0 accept
}
chain FORWARD {
type filter hook forward priority filter; policy accept;
}
chain OUTPUT {
type filter hook output priority filter; policy accept;
}
}Your iptables rule is an nftables rule. iptables-nft marks the tables it creates and nft prints that warning when it sees the mark, because editing such a table with nft puts two tools in charge of the same rules. Look at what one command produced: a table you did not name, and chains you did not ask for. That is the old model, and it is the first thing that changes when you write nftables directly.
What iptables -L hides from you
iptables -L shows the filter table only. NAT (network address translation) rules need iptables -t nat -L, and mangle rules need -t mangle. IPv6 lives in a separate command, ip6tables, with its own copy of every rule. A box can therefore look clean in one listing while something drops or rewrites your packets from a table you never checked.
sudo nft list ruleset prints every family, every table, every chain and every rule in one output. On a server you did not build yourself, that single command is the fastest way to see what is really loaded. Add -a to print rule handles, which you need in order to delete one rule instead of the whole chain.
Two habits are worth fixing while you are here. iptables -L resolves addresses and ports into names, so on a box with a broken resolver it looks like it has hung: use iptables -nvL. And confirm the legacy back end is empty with sudo iptables-legacy -nvL, because if rules exist in both back ends the kernel evaluates both, and neither listing shows you the whole picture.
Tables and chains you create, not inherit
nftables starts with nothing. There is no filter table until you make one, and the word filter is only a name you picked. A chain sees packets only when you give it a type, a hook and a priority, which makes it a base chain. A chain without those is reached only by an explicit jump or goto, so it costs nothing until something jumps to it.
The other large change is the inet family. One inet table handles IPv4 and IPv6 in the same rules, which removes a whole class of bug where a port is closed in iptables and wide open in ip6tables. That mismatch is common enough to have its own failure mode on ufw boxes.
Here is a complete server ruleset. It goes in /etc/nftables.conf.
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
set admin_ips {
type ipv4_addr
flags interval
elements = { 203.0.113.5, 198.51.100.0/24 }
}
chain input {
type filter hook input priority filter; policy drop;
ct state established,related accept
ct state invalid drop
iif lo accept
ip protocol icmp accept
meta l4proto ipv6-icmp accept
tcp dport 22 ip saddr @admin_ips counter accept
tcp dport { 80, 443 } counter accept
}
chain forward {
type filter hook forward priority filter; policy drop;
}
chain output {
type filter hook output priority filter; policy accept;
}
}Read line 2 twice. flush ruleset deletes every table on the box, including the tables ufw and Docker created for themselves. Keep reading before you run this on a live server.
The first rule in the input chain does most of the work. ct state established,related accept lets replies to connections you started come back in, so the rest of the chain only has to decide about new connections. ct state invalid drop throws away packets that match no known connection and no valid start. Everything after that is an explicit hole, and policy drop handles the rest.
Check the file before you load it, and keep a second SSH session open while you do. policy drop plus one typo in the SSH rule locks you out of your own server.
sudo nft -c -f /etc/nftables.conf
sudo nft -f /etc/nftables.conf
sudo nft list rulesetnft -c -f parses the file and reports errors without loading anything. A clean parse prints no output at all.
Sets replace long rule lists
tcp dport { 80, 443 } is an anonymous set: one rule and one lookup, instead of one rule per port. A named set such as admin_ips goes further, because you can change it while the firewall is running.
sudo nft add element inet filter admin_ips { 203.0.113.9 }
sudo nft list set inet filter admin_ips
sudo nft delete element inet filter admin_ips { 203.0.113.9 }No reload, no rule renumbering, and the match stays a single lookup whether the set holds five addresses or fifty thousand. flags interval is what lets a set hold ranges and CIDR (classless inter-domain routing) prefixes like 198.51.100.0/24. Without that flag the set takes single addresses only, and loading the prefix fails.
Sets can also expire their own elements.
set banned {
type ipv4_addr
flags timeout
timeout 1h
}With a rule ip saddr @banned drop, each element removes itself an hour after it was added. That is how the nftables action in fail2ban on Ubuntu 24.04 bans an address: it adds an element to a set, it does not add a rule. If ports themselves are new ground, start with what a port actually is on Linux.
One difference catches people during a migration. nftables does not count packets unless you ask it to. iptables -nvL shows counters for every rule, always. In nftables only rules carrying the counter keyword have numbers, so put counter in any rule you expect to debug later.
How hooks and priorities decide the order
A base chain names a hook, which is the point in the packet path where it runs. prerouting runs before the routing decision. input runs for packets addressed to this machine. forward runs for packets routed through it. output runs for packets from local processes. postrouting runs last, just before the packet leaves.
Priority orders the chains inside one hook, lowest number first. nftables gives the classic values names: raw is -300, mangle is -150, dstnat is -100, filter is 0, srcnat is 100. Writing priority filter; is the same as writing priority 0;.
Now the part that decides whether mixing tools works. Every base chain registered on a hook runs, in priority order. A packet accepted in your chain is not finished: accept ends that chain only, and the packet carries on to the next base chain on the same hook. drop is final everywhere and stops the packet at once. So a permissive rule in your table cannot undo a drop in ufw's table, whichever runs first, and your accept buys you no protection from a chain that runs later.
Two base chains on the same hook with the same priority run in registration order, which depends on which service started first. That order can change across a reboot. If you must run your own table beside ufw, give it a distinct priority so the order is written down instead of raced for.
Why is there no reverse NAT rule to write?
This is the question people get wrong most often, so here is the direct answer. Connection tracking writes the reverse translation for you. There is no second rule to add.
A nat table doing both halves of the usual VPS job looks like this.
table inet nat {
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
iifname "enp1s0" tcp dport 8080 dnat ip to 10.0.0.5:80
}
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
ip saddr 10.0.0.0/24 oifname "enp1s0" masquerade
}
}Only the first packet of a connection is evaluated against a nat chain. When a rule matches, the kernel stores that translation in the connection tracking table alongside the connection's entry. Every later packet, in both directions, is rewritten from the stored entry, and no rule is read again. Install the conntrack tool and look at a live entry.
sudo apt install -y conntrack
sudo conntrack -L -p tcptcp 6 431999 ESTABLISHED src=198.51.100.20 dst=203.0.113.10 sport=54321 dport=8080 src=10.0.0.5 dst=198.51.100.20 sport=80 dport=54321 [ASSURED] mark=0 use=1Read it as two tuples. The first four fields are the connection as the client sent it, addressed to 203.0.113.10:8080, your public address. The second four are the reply the kernel expects, already reversed and already translated, coming from 10.0.0.5:80, the real backend. That second tuple is the reverse rule. The kernel wrote it when the first packet matched.
So do not write a rule for the return direction. It cannot match, because return packets belong to an established connection and never reach a nat chain, and if it somehow did match you would translate a packet the kernel had already fixed.
Where a rewrite must sit follows from the same mechanism. Destination translation has to run in prerouting, before the routing decision, because routing must see the new destination or the packet goes to the wrong place. Traffic the box generates itself is handled in the output hook for the same reason. Source translation, including a source port rewrite, has to run in postrouting, after routing has chosen the outgoing interface. masquerade takes its address from that interface, and the interface is not known until routing has run.
That is why a rule like this belongs at the end of the path and nowhere else.
ip saddr 10.0.0.0/24 oifname "enp1s0" snat ip to 203.0.113.10:20000-30000The port range rewrites the source port along with the source address, which is what you want when many internal clients share one public address and their source ports collide. A reply arrives addressed to a port in that range, conntrack matches it to the entry, and the original source port is put back before the packet is delivered. Again, no second rule.
One practical consequence: changing a NAT rule does not move connections that already exist, because their translation is already stored. They keep the old behaviour until their entries expire. sudo conntrack -D -p tcp --dport 8080 deletes the matching entries and sudo conntrack -F deletes all of them. Treat the second one with care on a NAT box, because those stored translations are what keeps current connections alive, so flushing them breaks every connection through the box at once.
ufw and Docker both write rules of their own
ufw is a front end to iptables, which on Ubuntu is a front end to nftables. A ufw box therefore has an ip filter table full of chains named ufw-before-input, ufw-user-input and so on, plus an ip6 filter copy of the same structure. Look with sudo nft list ruleset | grep ufw. Those chains are generated from the files in /etc/ufw, and ufw reload rewrites them from scratch, which is why a hand-written iptables rule added on top vanishes at the next reload. The ufw basics for a VPS cover that file layout.
Docker programs the firewall itself and does not consult ufw. Publishing a port with -p 80:80 writes a DNAT rule into the nat table and an accept into the forward path, and both run before ufw's user chains. The result surprises everyone once: ufw deny 80 is loaded, and the container is still reachable from the internet. The fix lives in the DOCKER-USER chain that Docker leaves for your rules, and why Docker containers ignore ufw walks through it. See what is on your box with sudo nft list ruleset | grep -i docker.
Now re-read that flush ruleset line from the config above. It deletes every table, including the tables those two tools manage. On a Docker host, published ports stop working until sudo systemctl restart docker rebuilds the chains. That one line is the most common way people take their own services offline while tidying up a firewall.
Rules that survive a reboot
Neither ruleset is persistent on its own. The kernel forgets everything at shutdown, and each side solves it with a separate package.
For nftables, /etc/nftables.conf is read by nftables.service. Ubuntu ships that service disabled, so check it before you trust it.
systemctl is-enabled nftables
sudo nft -c -f /etc/nftables.conf
sudo systemctl enable --now nftablesFor iptables, the package is iptables-persistent, which installs netfilter-persistent and saves to /etc/iptables/rules.v4 and /etc/iptables/rules.v6.
sudo apt install -y iptables-persistent
sudo netfilter-persistent saveDo not run both. Two files that each claim to hold the firewall will drift apart, and the one that loads last wins in a way nobody can predict by reading either file.
There is a related trap in dumping a live ruleset. sudo nft -s list ruleset > /etc/nftables.conf captures everything loaded at that moment, including ufw's tables and Docker's tables. Restore that at boot and you get a frozen copy of rules those tools expect to build themselves, then a second copy once they start. Dump your own table only, with sudo nft -s list table inet filter. The -s flag leaves out the counters, which do not belong in a config file.
Should you switch on your VPS?
Leave ufw alone unless you need something it cannot express. ufw covers the ordinary VPS job: a default deny with a handful of open ports. Replacing that with a hand-written ruleset for its own sake gives you the same firewall plus one more thing to maintain.
Go native when what you need is outside ufw's model: NAT and port forwarding, sets you update at runtime, one rule covering both address families, or chain priorities you choose yourself. Those are real reasons, and ufw has no way to say any of them.
If you go native, go native completely. Run sudo ufw disable and sudo systemctl disable --now ufw, confirm with sudo nft list ruleset that its tables are gone, then load your own file. A box running ufw and a hand-written table together still passes traffic, but the live policy is now the union of two rulesets evaluated in an order set by service startup, and nobody reading either file can tell you what the box actually does.
Migrating an existing iptables ruleset
iptables-translate converts one rule and prints the nftables form. It changes nothing on the box.
iptables-translate -A INPUT -p tcp --dport 22 -j ACCEPTnft add rule ip filter INPUT tcp dport 22 counter acceptiptables-restore-translate -f /etc/iptables/rules.v4 does the same for a whole saved ruleset. Treat its output as a first draft. The conversion is mechanical and rule for rule, so you get back the old table and chain names, two separate rulesets for IPv4 and IPv6, and none of the sets that made the move worth doing. Rewrite it as one inet table by hand, then check it with nft -c -f before it goes near a live server.
The addresses in these examples come from the documentation ranges 203.0.113.0/24 and 198.51.100.0/24, and enp1s0 is an interface name. Take yours from ip route show default and ip -br addr instead of copying mine, because current Ubuntu images rarely call anything eth0.
FAQ
Is iptables deprecated on Ubuntu?
The command is not going away and it still works on Ubuntu 24.04. What changed is what happens underneath: iptables is a front end that writes nftables rules through the iptables-nft back end. Check yours with iptables -V, which prints iptables v1.8.10 (nf_tables) on 24.04. The old x_tables back end still ships as iptables-legacy, and it holds a completely separate ruleset, so put rules in one back end and not both.
Do I need a second rule to undo NAT on the way back?
No. Connection tracking stores the translation when the first packet of a connection matches a nat rule, and every later packet in both directions is rewritten from that stored entry. sudo conntrack -L shows it as two tuples per connection: the original direction, then the already reversed reply. A rule written for the return direction cannot help, because return packets never reach a nat chain.
Can I run ufw and my own nftables rules at the same time?
It works, but you are buying a problem. Every base chain on a hook runs, so the live policy is both rulesets combined, ordered by priority and, at equal priority, by whichever service started first. A drop in either one is final, and an accept in yours does not stop the other from dropping the same packet. Pick one tool. If it is nftables, disable ufw first and confirm its tables are gone from sudo nft list ruleset.
How do I make nftables rules survive a reboot on Ubuntu?
Put the ruleset in /etc/nftables.conf, check it with sudo nft -c -f /etc/nftables.conf, then run sudo systemctl enable --now nftables. The service is not enabled by default, so systemctl is-enabled nftables is worth running once. When you generate that file, dump only your own table with sudo nft -s list table inet filter, because a full list ruleset dump also captures the tables ufw and Docker manage for themselves.