I'd been putting this off for years. The iptables rules on my main server had grown by accretion: a base set, some Docker-shaped chaos, a few hand-added DNAT lines I no longer trusted, and a parallel ip6tables config that was always slightly out of sync with the v4 one. It worked, mostly, in the way that load-bearing duct tape works. Debian moving to nft as the default backend was the nudge I needed to actually do the migration rather than just nodding at it.
The thing that finally sold me on nftables isn't speed, though the in-kernel handling is tidier. It's that one ruleset covers both v4 and v6. With the inet family you write a rule once and it applies to both, which immediately deletes the entire category of bug where IPv6 was wide open because I'd forgotten to mirror a rule.
Here's the shape of it, and it reads like actual configuration rather than a pile of -A incantations:
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
ct state established,related accept
iif "lo" accept
tcp dport { 22, 80, 443 } accept
ip protocol icmp accept
ip6 nexthdr icmpv6 accept
}
}
That { 22, 80, 443 } is an anonymous set, and named sets are even better: you can maintain a blocklist as a set and add to it without re-evaluating a chain of twenty rules. The whole thing is one file, one nft -f, atomic apply. No more half-applied rulesets when a line fails in the middle.
iptables-translate got me most of the way mechanically, but the rules it spits out are a literal transliteration and miss the point. The win is in rewriting them to actually use the new idioms. I kept the old rules around for a week, watched the logs, and then deleted them with the small satisfaction of paying off a debt I'd carried too long.