Your iptables Rules Are Running on a Compatibility Shim and You Know It
Here’s the thing: if you’re on Debian 12, Fedora 37+, RHEL 9, or basically any Linux distro from the last three years, the iptables binary you’ve been typing into muscle memory isn’t even real iptables anymore. It’s iptables-nft — a translation layer that converts your ancient rules into nftables kernel calls under the hood.
That’s not wrong, exactly. It works. Your rules land. Your servers don’t burn. But it’s the networking equivalent of writing Java code and running it through a transpiler to C forever — technically fine until the shim maintainers quietly stop caring, or until you try to do something the translation layer doesn’t handle cleanly.
nftables has been the kernel default since Linux 3.13. It shipped in Debian 10, RHEL 8, Fedora 32. We’re in 2026. At some point “I’ll migrate later” becomes “I’m carrying technical debt with a firewall.” That’s a bad place to be.
Let’s actually move.
What You’re Getting With nftables (It’s Not Just iptables with a Facelift)
The iptables-to-nftables migration isn’t just syntax shuffling. The underlying model is genuinely different, and the differences pay off.
One ruleset for IPv4 and IPv6
With iptables you maintain two separate rulesets: iptables for IPv4 and ip6tables for IPv6. Miss a rule in one and you have a policy gap. With nftables, a single table can handle both — just use family inet instead of ip and your rules apply to both stacks. One file, one source of truth.
Named sets and maps (dynamic blocklists that don’t hurt)
This is the killer feature. iptables ipset integration was always a bolt-on. nftables has sets and maps as first-class citizens. You can define a named set of IPs, add to it dynamically, and reference it in rules — all without reloading the whole ruleset. This is how you do fail2ban-style blocking without the performance hit of iterating a massive chain.
set blocklist { type ipv4_addr flags dynamic, timeout timeout 1h}
chain input { ip saddr @blocklist drop}Adding a ban: nft add element inet filter blocklist { 192.168.1.100 }. Done. No lock, no flush, no reload.
Atomic rule loads
With iptables, there was no safe way to replace your entire ruleset atomically. iptables-restore was close but had race conditions on busy systems. nftables loads rules in a single kernel transaction — either the whole thing applies or none of it does. Your 2 AM self will appreciate this when you’re pushing a firewall config change on a production box.
Verdict maps
Verdict maps let you replace long chains of matching rules with a single lookup. Instead of ten rules checking source IPs one by one, you build a map and jump directly to a verdict. Cleaner rules, faster evaluation.
map service_map { type inet_service : verdict elements = { 22 : accept, 80 : accept, 443 : accept }}
chain input { tcp dport vmap @service_map}The /etc/nftables.conf Skeleton
Here’s a solid baseline config for a single server. This covers the 90% case: allow established/related, allow loopback, allow SSH/HTTP/HTTPS, drop everything else.
#!/usr/sbin/nft -f
flush ruleset
table inet filter { chain input { type filter hook input priority 0; policy drop;
# Loopback iif lo accept
# Established/related ct state established,related accept
# Drop invalid ct state invalid drop
# ICMPv4 ip protocol icmp accept
# ICMPv6 (required for IPv6 to work) ip6 nexthdr ipv6-icmp accept
# SSH tcp dport 22 accept
# HTTP/HTTPS tcp dport { 80, 443 } accept }
chain forward { type filter hook forward priority 0; policy drop; }
chain output { type filter hook output priority 0; policy accept; }}Load it: nft -f /etc/nftables.conf. Enable on boot: systemctl enable --now nftables.
Check what’s loaded: nft list ruleset.
Converting Existing Rules: Don’t Do It By Hand
You have iptables rules running somewhere. Maybe it’s a shell script, maybe it’s an iptables-save dump, maybe it’s rules baked into a config management tool. Either way, iptables-translate and iptables-restore-translate exist specifically so you don’t have to mentally map every flag.
Single rule translation:
iptables-translate -A INPUT -p tcp --dport 443 -j ACCEPTOutput:
nft add rule ip filter INPUT tcp dport 443 counter acceptBulk translation from a saved ruleset:
iptables-save > /tmp/my-rules.v4iptables-restore-translate -f /tmp/my-rules.v4 > /tmp/my-rules.nftSame thing for IPv6:
ip6tables-save > /tmp/my-rules.v6ip6tables-restore-translate -f /tmp/my-rules.v6 >> /tmp/my-rules.nftDon’t blindly apply the output. Read it. The translation is good but not perfect — it generates per-IP-family tables (ip filter / ip6 filter) instead of the cleaner inet filter. Merge them manually and you’ll end up with a nicer ruleset.
The Awkward Third-Party Problem: Docker, fail2ban, ufw
Here’s where it gets real. You don’t live alone on that server.
Docker
Docker still manages its own firewall rules via the iptables shim. When you start a container with a published port, Docker inserts iptables rules to handle NAT and forwarding. Those rules go through iptables-nft, which translates them into nftables rules — but in the ip nat and ip filter tables, not your clean inet filter table.
This works, but it means Docker rules live separately from your rules, and there can be weird interaction effects. The safest approach on a Docker host: leave the nftables forward chain at policy drop, and let Docker manage its own chains. Don’t try to merge Docker’s iptables rules into your nft config — you’ll chase your own tail.
If Docker’s firewall management is a problem (it bypasses ufw/nftables for published ports), look at --iptables=false in Docker’s config and managing Docker networking yourself — but that’s a whole other article.
fail2ban
fail2ban traditionally uses iptables or ip6tables to insert bans. As of fail2ban 0.11+, it supports nftables natively — set banaction = nftables (or nftables-multiport) in jail.local. This is the right move. Once you’re on direct nftables, fail2ban can manage its own nftables set and you get dynamic bans without the shim.
[DEFAULT]banaction = nftables-multiportbanaction_allports = nftables-allportsRestart fail2ban after the config change and verify with nft list ruleset — you should see a f2b-* chain appear when the first ban fires.
ufw
ufw is a wrapper around iptables. It uses iptables-nft on modern systems, so it technically works. But ufw doesn’t know about nftables directly — it’s still generating iptables commands and letting the shim translate them. If you’re running ufw, you’re not really running nftables; you’re running iptables syntax that gets double-translated.
The choice: keep ufw as your interface and accept the shim, or drop ufw and manage nftables directly. For home lab boxes with simple rules, ufw is fine. For anything where you care about the details — sets, maps, atomic reloads — write the nft config yourself.
Kubernetes kube-proxy: nftables Mode Is Default Now
If you’re running a Kubernetes cluster (even a single-node k3s setup), this matters. kube-proxy introduced nftables mode as an alpha feature in Kubernetes 1.29, reaching GA in 1.33. The project is actively deprecating the iptables mode, but iptables remains the default — you must opt in to nftables mode explicitly.
What this means in practice: kube-proxy can manage Service VIPs and NodePort forwarding via native nftables instead of the iptables shim. Better performance, cleaner rule management, and one less reason to keep iptables tooling around.
Check which mode you’re running:
kubectl get configmap kube-proxy -n kube-system -o yaml | grep modeIf you see mode: "" (empty string), you’re on the default iptables mode. If you see mode: nftables, you’ve already opted in. To migrate, set mode: nftables in the kube-proxy configmap and restart kube-proxy.
Migration Tactics: Single Host vs Fleet
Single host
- Export current iptables rules:
iptables-save > /tmp/before.v4 && ip6tables-save > /tmp/before.v6 - Translate:
iptables-restore-translate -f /tmp/before.v4 > /etc/nftables.conf - Clean up the output — merge
ip filterandip6 filterinto a singleinet filtertable - Dry-run:
nft -c -f /etc/nftables.conf(the-cflag checks without applying) - Apply:
nft -f /etc/nftables.conf - Verify connectivity (don’t close your SSH session yet)
- Enable:
systemctl enable nftables - If using fail2ban: switch to
banaction = nftables-multiport, restart
Keep the iptables rules around in /tmp for at least one maintenance window. If something breaks, iptables-restore < /tmp/before.v4 gets you back instantly.
Fleet (Ansible)
For a fleet of servers, the workflow is basically: template your nftables.conf, push with Ansible, validate atomically.
- name: Deploy nftables config template: src: nftables.conf.j2 dest: /etc/nftables.conf validate: "nft -c -f %s" notify: reload nftables
handlers: - name: reload nftables service: name: nftables state: reloadedThe validate: line is key — Ansible runs nft -c -f on the rendered template before deploying it. Syntax errors fail the task, not the firewall. That’s exactly what you want on a remote box you can’t touch physically.
For per-host differences (different open ports, different allowed CIDRs), use Ansible variables in the template. Keep the core structure the same everywhere; only the sets and port lists vary.
Just Do It Already
The iptables shim isn’t going away tomorrow. Nobody’s pulling the rug. But every month you run it is another month of:
- Rules that live in two separate IPv4/IPv6 rulesets instead of one
- No native sets or maps — your blocklists are slower than they need to be
- Tooling (Docker, fail2ban, Kubernetes) moving to native nftables while your config stays behind
- A translation layer between your intent and the kernel, with all the subtle bugs that implies
The migration isn’t hard. iptables-restore-translate does most of the lifting. An afternoon of testing on a non-critical host and you’ll have a config you can actually read, modify, and reason about.
Honestly, the hardest part is just accepting that the thing you learned in 2014 isn’t the thing anymore. That’s fine. nft list ruleset is way more readable than iptables -L -n -v anyway.