Ramblings of an aging IT geek
← Ramblings of an aging IT geek
linux

moving the firewall to nftables, at long last

Migrating a homelab firewall from iptables to nftables, what the new syntax actually buys you, and the one habit from the old world that took the longest to drop.

A terminal showing nftables ruleset output

I'd been putting this off for years, in the way you put off any rewrite of something that already works. iptables had guarded my boxes since forever, the rules were a bit gnarly but I understood them, and "if it isn't broken" is a powerful sedative. But nftables has been the default backend on recent kernels for a while now, the tooling has settled, and I finally ran out of excuses on a quiet Saturday.

The first thing that hits you is that the four-tool circus is gone. No more iptables, ip6tables, arptables, and ebtables as separate worlds with separate syntaxes. It's one nft command and one ruleset, and IPv4 and IPv6 live in the same place instead of being maintained as two parallel realities that drift apart the moment you stop paying attention. If you've ever fixed a rule in iptables and forgotten the ip6tables twin, you'll feel the relief immediately.

The ruleset itself reads like something a person designed on purpose. Here's roughly the shape of my base policy:

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

        ct state established,related accept
        iif "lo" accept
        ct state invalid drop

        tcp dport { 22, 80, 443 } accept
        ip protocol icmp accept

        counter
    }
}

A few things in there are quietly lovely. table inet means this one table handles both IPv4 and IPv6, so that block above is the whole story rather than half of it. The set syntax, { 22, 80, 443 }, is native: in iptables you'd either repeat the rule three times or reach for a module, here you just write the set. And the policy and the hook are declared right at the top of the chain, so "default drop on input" is one line you can actually see, not an implicit state you have to infer from the absence of an ACCEPT at the bottom.

A close-up of an nftables config file in an editor

The migration itself was gentler than I'd feared. There's iptables-translate, which takes an iptables rule and prints its nftables equivalent, and it's genuinely useful for getting your eye in. I didn't use it to convert wholesale, because the output is a faithful but ugly transliteration that keeps the iptables shape rather than embracing the new one. I used it to learn the mapping, then wrote the ruleset by hand the way nftables wants to be written, with named sets and a single inet table. Slower, but I ended up with something I'd actually want to maintain rather than a mechanical port of my old mistakes.

The habit that took longest to drop was reaching for -A thinking. In iptables you append rules to chains and order is everything and you're forever counting line numbers to insert in the right place. nftables still cares about order within a chain, but the whole ruleset is a document you load atomically with nft -f, so I stopped poking individual rules and started editing a file and reloading the lot. That single change, treating the firewall as a file under version control rather than a sequence of imperative commands typed at a prompt, is most of the value. The new syntax is nicer, sure, but the real win is that my firewall is now a thing I can read top to bottom, diff, and reason about, instead of a pile of appended rules whose order I half-trusted and never fully understood.