Tuesday, 2:47 a.m. I'm sitting in the living room, laptop on the coffee table, about to add a new IP address to my firewall whitelist. A friend switched providers, so his address range changed. Should have been a five-minute job: open the file, add a rule, run the script. Instead I'm standing in the basement twenty minutes later, staring at a monitor hooked to my server's console, wondering how my firewall ended up in a half-loaded state.

The script was called /etc/firewall.sh, and it had grown the way such scripts grow. It started with iptables -F to flush every chain. Then came 47 lines of rules: first the whitelist as fifteen individual -s rules, then the services, and at the very end an -A INPUT -j DROP as a catch-all. One of those lines was new that night, and it contained a typo. Instead of the netmask 255.255.255.192 there was 255.255.255.0. Computed in my head, of course. iptables answered with an error message, and because the script ran with set -e, it stopped dead at that exact line. Rule 37 of 47 never executed. The chains were flushed, the whitelist rules before the typo were loaded, but everything after it was missing, including the DROP at the end. The default policy was ACCEPT. As of 2:47 a.m., my server was online without a firewall.

Ten minutes passed before I noticed. Nothing happened, a homelab in a residential neighborhood isn't a particularly attractive target. But the lesson stuck: the problem wasn't the typo. The problem was a system that could end up in a half-state at all. A firewall should either be fully active or not active. Since that night the server runs nftables, and that's what this article is about: moving from a 47-line iptables script to an nftables ruleset, with sets, the inet family, atomic reloads, and counters that show me what actually flows through the wire.

What nftables does differently: one table for everything

nftables has been part of the Linux kernel since 3.13 (January 2014) and is the official successor to the iptables framework. The biggest difference sits in the architecture. iptables spreads filtering logic across four separate tools: iptables for IPv4, ip6tables for IPv6, arptables for ARP, and ebtables for bridging, each with its own syntax and its own tables. nftables folds all of that into a single command, nft, and one consistent rule language.

The heart of it is the address family. A table of type inet handles IPv4 and IPv6 at the same time: a table inet filter contains chains that apply to both protocol families. Before, I had the 47 iptables rules for IPv4 and a separate file with twelve IPv6 rules that was three years old and basically only allowed SSH. In other words, my IPv6 stack was practically wide open, and I didn't even know it precisely. With inet, that split no longer exists. A rule applies to both worlds, or you say explicitly that it should apply to only one.

Then there's the data structure. Where iptables stores rules linearly, nftables has sets: named collections that you define once and reference from any number of rules. That sounds unspectacular, but it's the reason my whitelist shrank from fifteen individual rules to four set elements. More on that in its own section.

The old setup: 47 rules, six chains, zero counters

So the numbers here don't float in the void, here's the old setup in a nutshell. The server is a small homelab box, a Ryzen with 16 GB of RAM, doing duty as file server, media server, and occasional VM host. It sits behind a router that forwards ports 22, 80, 443, and 51820/UDP inward. The firewall on the server itself is the last filter in front of the services.

The iptables script had 47 rules spread across six chains. Fifteen of them were whitelist entries: -A INPUT -s 192.168.10.0/24 -j ACCEPT, followed by fourteen more lines of the same pattern. Then came the service rules, a few exceptions for established connections, and the final DROP. Six chains, because mangle and nat leftovers had crept in over the years and nobody could properly assign them anymore. Nobody cleaned them up either, because iptables offered no built-in way to compare the rules actually active against the file on disk.

Above all: not a single rule had a counter. When I later wanted to know how much traffic actually arrives on port 22, the only option was tcpdump on the interface. On a server that runs 24/7, that's like measuring your power consumption by glancing at the meter once a month and guessing the rest of the time.

# /etc/firewall.sh (excerpt, before the migration)
iptables -F
iptables -A INPUT -s 192.168.10.0/24 -j ACCEPT
iptables -A INPUT -s 10.30.0.0/26 -j ACCEPT
... 13 more whitelist lines ...
iptables -A INPUT -p tcp --dport 22 -j ACCEPT
iptables -A INPUT -p tcp --dport 80 -j ACCEPT
iptables -A INPUT -p tcp --dport 443 -j ACCEPT
iptables -A INPUT -p udp --dport 51820 -j ACCEPT
iptables -A INPUT -j DROP

Six chains, 47 rules, zero counters, and a reload that wiped everything first and rebuilt from scratch on every run. If the script failed halfway through, the server was unprotected. That was exactly the night in February.

The migration: one file, three chains, two sets

The new ruleset lives in /etc/nftables.conf and ends up being a fraction of the old script. One table of type inet, three base chains, two named sets. The rest is readability.

#!/usr/sbin/nft -f

table inet filter {
  set whitelist {
    type ipv4_addr
    flags interval
    elements = { 192.168.10.0/24, 10.30.0.0/26,
                 100.64.0.0/10, 172.16.42.0/24 }
  }
  set services {
    type inet_service . inet_proto
    elements = { 22 . tcp, 80 . tcp, 443 . tcp,
                 51820 . udp }
  }

  chain input {
    type filter hook input priority filter; policy drop;
    counter packets 0 bytes 0
    ct state established,related accept
    ip saddr @whitelist accept
    tcp dport @services accept
    udp dport @services accept
    counter
  }

  chain output {
    type filter hook output priority filter; policy accept;
    counter
  }

  chain forward {
    type filter hook forward priority filter; policy drop;
    counter
  }
}

It looks different from the old script, and for good reasons. The whitelist is now a set with four entries instead of fifteen individual rules. Because the set is defined with flags interval, it accepts whole ranges: 192.168.10.0/24 is one element, not a mask I have to remember. The service set uses a concatenation: inet_service . inet_proto means every element is a pair of port and protocol. 22 . tcp is a different thing from 22 . udp, and still the rule tcp dport @services accept stays a single line.

The chains are openly named input, output, and forward, with the hooks behind them in parentheses. Every base chain gets a policy. For input and forward it's drop, for output it's accept. That inverts the old logic: instead of appending a DROP at the end, dropping is now the default state, and every rule deliberately opens a hole. If you forget to write a rule, you end up with a closed firewall, not an open one.

The hooks: where packets actually travel

If you're used to iptables, you know the chains INPUT, OUTPUT, and FORWARD. nftables makes visible what's behind them: the kernel calls functions at five fixed points in the network stack, the Netfilter hooks. A packet arriving from outside runs through PREROUTING first. Then routing decides whether it gets delivered locally (then INPUT) or passed on (then FORWARD). Traffic the server generates itself starts in OUTPUT. Everything leaving the machine passes POSTROUTING at the end.

Netfilter hooks: packet flow through PREROUTING, INPUT, FORWARD, OUTPUT, and POSTROUTING with accept/drop decisions and counters

The graphic shows the flow with the counter values after thirty days of uptime. PREROUTING saw 12.4 million packets in that time, INPUT only 1,240,318. The difference is the part the firewall dropped: port scans, bots, broadcast noise. FORWARD sits at zero because the server isn't a router. OUTPUT is at 891,402 packets, dominated by backups and updates. POSTROUTING sums OUTPUT and FORWARD, so roughly 13.3 million.

Those numbers weren't obtainable with iptables, at least not without adding a counter to every single rule. In nftables, counter is a keyword you can attach to any rule. The bare line counter at the end of the input chain counts everything that wasn't accepted up to that point, meaning the discarded remainder. nft list ruleset shows the counter values in seconds, and if you want, you can hook monitoring into nft list counters.

Atomic reload: the difference between night and day

Back to the night in February. The real problem wasn't the typo; it was that a failure in the middle of loading left a half-state behind. That's exactly what can't happen with nft -f. nftables builds the entire ruleset in memory first, validates it, and activates it in a single step. Either the new ruleset applies completely, or the old one stays in effect. There is no intermediate state.

On top of that comes nft -c (check): it lets you validate a file without applying it. nft -c -f /etc/nftables.conf reports syntax errors, unknown names, and wrong types before a single packet is affected. My workflow since then: edit the file, run nft -c -f, and only when that's green, systemctl reload nftables. The old process took about seven seconds, because each of the 47 iptables lines spawned its own process. The nftables reload is done in a few milliseconds. Seven seconds versus milliseconds, and one of those paths is safe while the other isn't.

One detail worth mentioning, because it catches most people switching over: nft flush ruleset really deletes everything, including your own SSH rule. Run that by hand over an SSH session and you've closed the door behind yourself. Inside a file it's harmless, because the reload is atomic and the new ruleset contains the SSH rule again anyway. By hand, over a live connection, it's one of the fastest ways to lock yourself out of your own server. The old iptables reflex of flushing first and rebuilding afterward isn't just unnecessary in nftables, it's actively dangerous.

Sets in practice: why the whitelist ended up in the Subnet Calculator

The real win of the migration is the sets, and it shows in daily use. Adding a new IP to the whitelist now means: nft add element inet filter whitelist { 10.30.0.64/26 }, done. No editing a script file, no reload, no process spawns. The set is live, and the rule referencing it stays untouched. And because the entry is a CIDR block, one line covers a whole subnet when a friend has received an address range from his provider rather than a single host.

That's exactly where the bitcalc Subnet Calculator slipped back into my daily routine. The February night was, at bottom, a netmask problem: 255.255.255.192 is the mask of a /26, 255.255.255.0 the mask of a /24. If you compute values like that in your head, sooner or later you make exactly this mistake. Since then I run every range through the calculator before it goes into a set: how many hosts fit into the friend's /26? Does 10.30.0.0/26 overlap with the tunnel network 100.64.0.0/10 from the VPN story? The Subnet Calculator shows network address, host range, broadcast, and size at a glance, and hand-checking for overlaps is obsolete. The whitelist set now has four elements, and I've run every one of them through the calculator first.

There's another advantage of sets that you only notice in operation: the rule ip saddr @whitelist accept is a single line whether the set has four entries or four hundred. With the old script, the whitelist grew by one line per entry, and at some point you start wondering whether there's an outdated IP lurking somewhere. With a set, the answer is one query: nft list set inet filter whitelist.

Counters as a diagnostic tool

The biggest invisible win is the counters. I had no idea how much of what arrives at my server is actually wanted, until the numbers were on the table. The 12.4 million packets in PREROUTING versus 1,240,318 in INPUT mean: roughly 90 percent of incoming traffic gets discarded. Most of that is port-scan noise, but there was a surprise in there too. The input chain counters showed a steady stream of packets on port 445, that is, SMB. From outside. My server doesn't offer SMB at all, and the router shouldn't have been forwarding that port either. Result of the investigation: a forgotten port forward from the time I was experimenting with Windows file shares. That rule sat in the router config for three years, and nobody ever used it. Without counters I would never have seen it, because nothing was broken. It was just needlessly open.

Since then I look at the counter values once a month, the way you glance at the electricity meter. Five minutes, nft list ruleset, and I know whether something has changed. If a service suddenly sees more traffic than usual, that's a hint before it becomes a problem.

What stumbles on the way over: three pitfalls

To be fair: the migration hurt too. Three things cost me the most time.

First, the syntax. nftables is its own language, not an iptables variant. -A INPUT -p tcp --dport 22 -j ACCEPT becomes tcp dport 22 accept. After two days that reads more naturally than the old flag soup, but the first hours are painful. The man page and nft -c are your best friends there.

Second, NAT. For masquerading, nftables needs a separate table of type ip, because the inet table isn't reliably usable for NAT intervention depending on kernel version. Anyone converting their router will reliably trip over this. In my case the router did the NAT and the server only filtered, so it was easy. If you run both on one box, plan the nat table in from the start.

Third, priorities. Base chains attach to a hook with a priority number, and the order of tables decides who sees first. Standard filter tables run at priority 0, and as long as you have one table, it's simple. As soon as a second table enters the picture, say for an IDS, you need to know the priorities, or one table sees packets the other already dropped.

Bottom line

A year after the February night, the verdict is clear. 47 iptables rules in six chains have become one table with three chains and two sets. The reload that used to take seven seconds and could break is now an atomic step of a few milliseconds, and nft -c catches errors before they take effect. The whitelist shrank from fifteen individual rules to four set elements, IPv4 and IPv6 share one ruleset, and every hook counts along.

But the most important thing is the feeling you can't measure: the firewall can no longer end up in a half-state. The script from the February night is still on disk, as a reminder. Every now and then I open it and look at line 37, the one with the wrong netmask. These days I run every mask through the bitcalc Subnet Calculator before it lands anywhere. And the rule itself long ago became a set element in an nftables file that can't say goodbye halfway through.