Security
14 min readApril 22, 2026

Migrating from iptables to nftables: A Production Engineer's Guide

iptables has been the Linux firewall workhorse for over two decades. nftables replaces it with a unified, faster, and far more maintainable framework. Here's how to migrate without breaking production.

AJ
Ajeet Yadav
Platform & Cloud Engineer
Migrating from iptables to nftables: A Production Engineer's Guide

I've spent more hours than I care to admit staring at iptables output that looks like this:

-A FORWARD -s 10.244.0.0/16 -m comment --comment "flannel forwarding" -j ACCEPT
-A FORWARD -d 10.244.0.0/16 -m comment --comment "flannel forwarding" -j ACCEPT
-A POSTROUTING -s 10.244.0.0/16 ! -d 10.244.0.0/16 -m comment --comment "masquerade non-local traffic" -j MASQUERADE

Three rules. Three separate -A commands. Three passes through the kernel's netfilter subsystem. On a busy Kubernetes node with 200+ pods, that's thousands of rules evaluated sequentially for every packet.

nftables doesn't work like that. And if you're still running iptables on anything newer than Ubuntu 20.04 or Debian 10, you're leaving performance and maintainability on the table.

What's Actually Wrong with iptables

iptables isn't broken — it still works. The problem is architectural.

Four separate tools for one job. iptables, ip6tables, arptables, and ebtables all have independent rule sets, independent chains, and independent userspace tools. Want a rule that applies to both IPv4 and IPv6? Write it twice. Want to inspect all your firewall state in one place? Good luck.

Linear rule evaluation. Every packet walks every rule in every chain from top to bottom until it hits a match. On a host with a thousand iptables rules — completely normal on a mid-sized Kubernetes cluster — that's a thousand comparisons per packet, per chain. The performance profile doesn't scale.

No atomic rule updates. iptables-restore is the best you get, and it's not truly atomic at the kernel level. There's a window during a rule flush-and-reload where packets can slip through or get dropped incorrectly. On a production cluster doing a kube-proxy sync, this matters.

No native set support. Want to match against 50 IP addresses? Write 50 rules, or use ipset as an external dependency. nftables has sets built in.

nftables fixes all of this. One framework for IPv4, IPv6, ARP, and bridge filtering. A JIT-compiled bytecode VM in the kernel for packet matching. Atomic rule transactions. Native set and map support. And a syntax that a human can actually read six months after writing it.

The nftables Mental Model

Before translating rules, you need to understand how nftables organises things. The hierarchy is: table → chain → rule.

table inet filter {
    chain input {
        type filter hook input priority 0; policy drop;

        ct state established,related accept
        iif lo accept
        tcp dport 22 accept
    }
}

A few things to notice:

inet covers both IPv4 and IPv6. No more writing rules twice. You can also use ip for IPv4-only or ip6 for IPv6-only tables, but inet is almost always what you want.

Chains declare their hook and priority explicitly. In iptables, INPUT, FORWARD, and OUTPUT are fixed names with implicit hooks. In nftables, you name the chain whatever you want and attach it to a hook (input, forward, output, prerouting, postrouting) at a specific priority. Lower priority numbers run first.

Policy is declared on the chain, not implied. policy drop means unmatched traffic drops. policy accept means it passes. No guessing.

Rules read left to right. ct state established,related accept — connection tracking state matches first, action second. Much more legible than iptables's -m state --state ESTABLISHED,RELATED -j ACCEPT.

Mapping iptables Concepts to nftables

iptablesnftables
iptables -t filtertable ip filter
ip6tables -t filtertable ip6 filter
iptables -t nattable ip nat
iptables -t mangletable ip mangle
-j ACCEPTaccept
-j DROPdrop
-j RETURNreturn
-j MASQUERADEmasquerade
-m state --statect state
-s 10.0.0.0/8ip saddr 10.0.0.0/8
-d 10.0.0.0/8ip daddr 10.0.0.0/8
-p tcp --dport 443tcp dport 443
-i eth0iif eth0 (or iifname "eth0")
-o eth0oif eth0 (or oifname "eth0")
ipsetnftables sets (built-in)

The iif/oif vs iifname/oifname distinction matters: iif matches by interface index (fast, interface must exist at rule load time), iifname matches by name string (slower, but works for dynamic interfaces like container veth pairs).

Exporting Your Existing iptables Rules

Before touching anything, dump your current rules:

bash
# IPv4
iptables-save > /root/iptables-backup-$(date +%Y%m%d).rules

# IPv6
ip6tables-save > /root/ip6tables-backup-$(date +%Y%m%d).rules

# Verify the dump looks complete
wc -l /root/iptables-backup-*.rules

Then use iptables-translate and ip6tables-translate to convert individual rules:

bash
iptables-translate -A INPUT -p tcp --dport 443 -j ACCEPT
# Output: nft add rule ip filter INPUT tcp dport 443 counter accept

iptables-translate -A POSTROUTING -t nat -s 10.0.0.0/8 -j MASQUERADE
# Output: nft add rule ip nat POSTROUTING ip saddr 10.0.0.0/8 counter masquerade

For bulk translation, iptables-restore-translate converts an entire saved ruleset:

bash
iptables-restore-translate -f /root/iptables-backup-$(date +%Y%m%d).rules > /root/nftables-translated.nft
ip6tables-restore-translate -f /root/ip6tables-backup-$(date +%Y%m%d).rules >> /root/nftables-translated.nft

Do not blindly apply the output. The translation is mechanical and produces working rules, but the result won't take advantage of nftables features. It also won't merge your IPv4 and IPv6 rules into a single inet table, which is one of the main benefits. Treat the translation as a reference, not a final config.

Writing Your nftables Config

The canonical location is /etc/nftables.conf. Here's a production-ready baseline for a server:

nft
#!/usr/sbin/nft -f

flush ruleset

table inet filter {

    # Blocklist set — add IPs here to drop silently
    set blocklist {
        type ipv4_addr
        flags interval
        elements = { }
    }

    chain input {
        type filter hook input priority 0; policy drop;

        # Drop blocklisted IPs early
        ip saddr @blocklist drop

        # Loopback
        iif lo accept

        # Established and related connections
        ct state established,related accept

        # Invalid packets
        ct state invalid drop

        # ICMP (rate-limited)
        ip protocol icmp icmp type { echo-request, echo-reply, destination-unreachable, time-exceeded } limit rate 10/second accept
        ip6 nexthdr icmpv6 icmpv6 type { echo-request, echo-reply, destination-unreachable, nd-neighbor-solicit, nd-neighbor-advert } limit rate 10/second accept

        # SSH
        tcp dport 22 ct state new limit rate 10/minute 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;
    }
}

table ip nat {
    chain prerouting {
        type nat hook prerouting priority -100;
    }

    chain postrouting {
        type nat hook postrouting priority 100;

        # Masquerade outbound traffic from private ranges
        ip saddr { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 } oifname "eth0" masquerade
    }
}

A few things worth calling out:

flush ruleset at the top clears everything before applying. This is intentional — it makes the config file the single source of truth. Without it, loading the config twice stacks rules.

set blocklist is a native nftables set. Adding an IP to it (nft add element inet filter blocklist { 1.2.3.4 }) takes effect immediately with no rule reload. This replaces the ipset dependency entirely.

limit rate is built-in, not a separate match module like -m limit in iptables.

Kubernetes and Docker Compatibility

This is where most people get surprised. Both Docker and Kubernetes (via kube-proxy or eBPF-based CNIs) heavily use iptables. When you switch to nftables on those hosts, you have two options:

Modern Linux distributions already ship with iptables-nft — an iptables-compatible frontend that writes rules to the nftables kernel subsystem instead of the legacy xtables backend.

Check which backend you're using:

bash
update-alternatives --display iptables
# If it shows /usr/sbin/iptables-nft, you're already on nftables underneath

On Debian/Ubuntu 21.04+, the default is already iptables-nft. Docker and Kubernetes tools call iptables and get nftables rules without knowing it. You can write your own rules in native nftables syntax while Docker/kube-proxy continue using their iptables commands — they all land in the same nftables kernel tables.

To switch explicitly:

bash
update-alternatives --set iptables /usr/sbin/iptables-nft
update-alternatives --set ip6tables /usr/sbin/ip6tables-nft

Option 2: Cilium (skip iptables entirely)

If you're on Kubernetes and can replace kube-proxy, Cilium with eBPF bypasses both iptables and nftables for service routing, using eBPF maps instead. It still uses nftables for host-level filtering, but kube-proxy's thousands of DNAT rules disappear. This is the right long-term direction for large clusters.

What to watch for on Kubernetes nodes

When running nftables alongside kube-proxy on the same node, inspect the full ruleset after any kube-proxy sync:

bash
nft list ruleset | grep -c "rule"

kube-proxy creates rules aggressively. A cluster with 100 services easily generates 1,000+ rules. Verify your custom rules aren't being shadowed by checking chain priorities — kube-proxy registers at priority -100 for nat prerouting and -100 for nat output. Your custom nat rules should run at a higher priority number (i.e., later) if you want kube-proxy's DNAT to take effect first, or lower (earlier) if you need to intercept before it.

Loading and Validating

bash
# Dry-run — check syntax without applying
nft -c -f /etc/nftables.conf

# Apply
nft -f /etc/nftables.conf

# Verify the ruleset loaded correctly
nft list ruleset

# Check a specific table
nft list table inet filter

# Enable on boot (systemd)
systemctl enable nftables
systemctl start nftables

For connection-level validation, run these from a separate machine or after confirming SSH still works in another terminal:

bash
# Confirm SSH is open
nc -zv <host> 22

# Confirm HTTP/HTTPS
curl -o /dev/null -s -w "%{http_code}" http://<host>
curl -o /dev/null -s -w "%{http_code}" https://<host>

# Confirm a blocked port is actually blocked
nc -zv <host> 3306 && echo "OPEN — check your rules"

Use nft monitor in another terminal to watch rule hits in real time while testing:

bash
nft monitor

Rollback Plan

Before you apply nftables to production, set up an automatic rollback:

bash
# Schedule a full restore in 5 minutes — cancel it once you've confirmed things work
echo "iptables-restore < /root/iptables-backup-$(date +%Y%m%d).rules" | at now + 5 minutes

# Apply nftables
nft -f /etc/nftables.conf

# Test — if everything's good, cancel the rollback
atrm $(atq | awk '{print $1}')

The at job is your safety net. If you lock yourself out, the old iptables rules come back automatically. On a cloud instance, you can also rely on your provider's console access as a last resort, but don't plan around it.

Common Migration Gotchas

iifname vs iif for container networking. Docker creates and destroys veth interfaces dynamically. iif matches by index and will break when the interface is recreated with a new index. Use iifname "veth+" with a wildcard for matching virtual interfaces.

State module differences. iptables ESTABLISHED,RELATED becomes nftables ct state established,related. The INVALID state that iptables implicitly handles needs an explicit ct state invalid drop rule in nftables — it won't drop invalid packets by default.

NAT table is IPv4-only by default. Despite using inet for your filter table, the nat table must be ip (IPv4) or ip6 (IPv6) — there's no inet nat in the standard nftables data model without additional configuration.

iptables-legacy and iptables-nft don't share state. If you switch the iptables alternative to iptables-nft but Docker was started with the legacy backend, Docker's rules are in the legacy xtables subsystem and nftables can't see them. Restart Docker after switching backends.

--line-numbers equivalent. In iptables you'd use iptables -L --line-numbers to get rule positions for deletion. In nftables, every rule has a handle:

bash
nft -a list chain inet filter input
# Shows rules with "# handle N" comments

# Delete by handle
nft delete rule inet filter input handle 7

Is It Worth It?

On a single-purpose server with 20 rules: probably not urgent. The operational overhead of the migration isn't zero.

On a Kubernetes node with hundreds of services and thousands of kube-proxy rules: yes, and the performance difference is measurable. The nftables JIT compiler evaluates sets in O(log n) rather than O(n), which matters when you're matching against large IP blocks or many service ClusterIPs.

The real argument for migrating now is maintenance trajectory. iptables is in maintenance mode. Distros are shipping nftables as default. New kernel netfilter features land in nftables first. The migration is a one-time cost; staying on iptables is an ongoing accumulation of technical debt.

If you're on Debian 10+, Ubuntu 20.04+, RHEL 8+, or any modern distribution, nftables is already installed and the kernel support is there. The only thing missing is the migration.

Lessons from the Field

Migrate the dev environment first, run it for a week. Not an hour — a week. Container networking edge cases surface during normal operations, not during a focused 30-minute test window.

Keep both configs in version control. The iptables backup and the nftables config should both be in git, with commit history showing the transformation. When an audit happens six months later, you want to be able to show exactly what changed and when.

Don't delete the iptables backup for 30 days. You'll think of a reason to reference it.

On managed Kubernetes (EKS, GKE, AKS): don't touch node-level firewall rules directly. The cloud provider's node bootstrapping and CNI plugins manage iptables/nftables on your behalf. Manage firewall rules through security groups, network policies, or your CNI's native tooling instead. This whole guide applies to self-managed nodes and bare metal.

Frequently Asked Questions

Can iptables and nftables rules coexist? Yes, if you're using iptables-nft backend — both land in the nftables kernel subsystem and the kernel evaluates them in priority order. If you're using iptables-legacy, they're in separate subsystems and don't interact, which can cause confusing behaviour.

Does nftables work with Fail2Ban? Yes. Fail2Ban has an nftables backend. Set banaction = nftables-multiport in jail.local. Fail2Ban creates its own nftables chain and inserts rules into it.

What about UFW? UFW is an iptables frontend. On Ubuntu, UFW with iptables-nft works transparently — UFW commands still function, they just write to the nftables kernel backend. You can run UFW and native nftables rules together, though managing both adds complexity.

Is nftables available in containers? It requires access to the host's netfilter subsystem, so it needs --privileged or CAP_NET_ADMIN. Inside an unprivileged container you can't manage nftables. That's expected behaviour — container networking is managed at the host level.

Related Topics

Linux
Networking
Security
nftables
iptables
Firewall
Kubernetes
DevOps

Read Next