> ## Content Index
> Fetch the complete content index at: https://www.pinglabz.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# nftables: The Modern Linux Firewall
- URL: https://www.pinglabz.com/nftables-linux/
- Published: 2026-08-19T17:42:22.000Z
- Updated: 2026-08-19T17:53:32.000Z
- Description: Your iptables rules already run on nftables. Handles, named sets, inet tables and atomic ruleset loads, all proven with real output from a live lab.
- Author: Jaime
- Tags: Linux, Firewall, Netfilter, Network Security, Labs

nftables is not a new firewall. It is the packet filtering engine that has been doing the work on your Linux boxes since kernel 3.13, quietly, underneath an `iptables` command that has been a translation layer for years. Learning `nft` is mostly learning to talk to something that is already running, and the payoff is a syntax where one rule replaces four, sets can be edited without touching rules, and a bad ruleset fails to load instead of half-applying.

This article is part of the [Linux networking commands guide](https://www.pinglabz.com/linux-networking-commands/), and it assumes you have read [the iptables fundamentals](https://www.pinglabz.com/iptables-linux/) or already know them. Everything below was captured on a Debian 13 host bridged into a CML topology: the host is at 10.77.0.100 on `ens224`, and SRV1 at 10.77.3.10 is three router hops away running iperf3 on 5201 and a Python HTTP server on 8080\. Every rule is scoped to that lab interface and subnet so the management path is never at risk.

## Proof that you are already running it

Start with an empty box and look at what `nft` reports:

```
j@llmbits:~$ sudo nft --version
nftables v1.1.3 (Commodore Bullmoose #4)

j@llmbits:~$ sudo nft list ruleset
table ip filter {
	chain INPUT {
		type filter hook input priority filter; policy accept;
	}

	chain OUTPUT {
		type filter hook output priority filter; policy accept;
	}
}
```

Those upper-case chain names are the giveaway. `nft` did not create them, `iptables` did, and the table is only there because something ran an `iptables` command at some point. Now write an iptables rule and look again through `nft`:

```
j@llmbits:~$ sudo iptables -A OUTPUT -o ens224 -d 10.77.3.10 -p tcp --dport 5201 -j DROP

j@llmbits:~$ sudo nft list ruleset
table ip filter {
	chain INPUT {
		type filter hook input priority filter; policy accept;
	}

	chain OUTPUT {
		type filter hook output priority filter; policy accept;
		ip daddr 10.77.3.10 oifname "ens224" tcp dport 5201 counter packets 0 bytes 0 drop
	}
}
```

Same rule, nftables syntax, because that is what it always was. This is the single most useful demonstration for anyone who thinks a migration is looming: the migration already happened, and the only decision left is which syntax you type.

It also explains a warning you will meet later. nftables knows which tables the compatibility layer owns and tells you to keep your hands off:

```
# Warning: table ip filter is managed by iptables-nft, do not touch!
# Warning: table ip6 filter is managed by iptables-nft, do not touch!
```

Take it seriously. Mixing native `nft` rules into a table that `iptables` manages produces a ruleset that `iptables -S` cannot fully print, and eventually something runs `iptables -F` and deletes work you did with the other tool. Native rules go in their own table, always.

## Translating what you already have

`iptables-translate` converts a rule without applying it. It is the fastest way to learn the syntax, because you write what you know and read back what it means:

```
j@llmbits:~$ /usr/sbin/iptables-translate -A OUTPUT -o ens224 -d 10.77.3.10 -p tcp --dport 5201 -j DROP
nft 'add rule ip filter OUTPUT oifname "ens224" ip daddr 10.77.3.10 tcp dport 5201 counter drop'

j@llmbits:~$ /usr/sbin/iptables-translate -A INPUT -i ens224 -p icmp --icmp-type echo-request -m limit --limit 2/sec -j ACCEPT
nft 'add rule ip filter INPUT iifname "ens224" icmp type echo-request limit rate 2/second burst 5 packets counter accept'

j@llmbits:~$ /usr/sbin/iptables-translate -t nat -A POSTROUTING -o ens224 -s 10.77.0.0/16 -j MASQUERADE
nft 'add rule ip nat POSTROUTING oifname "ens224" ip saddr 10.77.0.0/16 counter masquerade'
```

It lives in `/usr/sbin`, so a non-root shell will tell you `command not found` unless you give the full path. There is a matching `iptables-restore-translate` that takes an entire `iptables-save` file and hands back an nftables ruleset, which is how you convert a real production policy rather than one rule at a time.

Reading those three translations side by side teaches most of the grammar. Match expressions are written as `<family> <field> <value>` (`ip daddr 10.77.3.10`, `tcp dport 5201`), interfaces are `iifname` and `oifname`, and the verdict goes at the end. There are no `-m` module loads, because the matches are built into the expression language.

## Tables, chains and hooks: what actually changed

The concepts survive, but three of them are meaningfully different.

Tables are yours to name

There is no fixed set of five tables. You create a table, name it whatever you like, and it holds only the chains you put in it. Deleting the table deletes the policy.

The inet family covers both

A table in the `inet` family filters IPv4 and IPv6 in the same rules. No more maintaining a parallel `ip6tables` policy that drifts out of sync.

Chains declare their own hook

A chain says which hook it attaches to and at what priority. Multiple chains can share a hook, ordered by priority, which is how ufw, Docker and your own rules coexist.

Counters are opt-in

A rule counts packets only if you write `counter` in it. Slightly more typing, measurably less overhead on rules you never inspect.

Building a policy from scratch is three commands. Create a table, create a chain and tell it where to hook in, then add a rule:

```
j@llmbits:~$ sudo nft add table inet labfw
j@llmbits:~$ sudo nft add chain inet labfw output '{ type filter hook output priority 0 ; policy accept ; }'
j@llmbits:~$ sudo nft add rule inet labfw output oifname ens224 ip daddr 10.77.3.10 tcp dport 5201 counter reject
```

Quote the braces, or your shell will expand them before `nft` ever sees them. The result sits alongside the iptables-managed table without interfering with it:

```
j@llmbits:~$ sudo nft list ruleset
table ip filter {
	chain INPUT {
		type filter hook input priority filter; policy accept;
	}

	chain OUTPUT {
		type filter hook output priority filter; policy accept;
	}
}
table inet labfw {
	chain output {
		type filter hook output priority filter; policy accept;
		oifname "ens224" ip daddr 10.77.3.10 tcp dport 5201 counter packets 0 bytes 0 reject with icmp port-unreachable
	}
}

j@llmbits:~$ nc -zv -w 3 10.77.3.10 5201
nc: connect to 10.77.3.10 port 5201 (tcp) failed: Connection refused
j@llmbits:~$ nc -zv -w 3 10.77.3.10 8080
Connection to 10.77.3.10 8080 port [tcp/http-alt] succeeded!
```

The priority number matters more than it looks. `priority 0` is `filter`, and a chain at a lower number runs earlier on the same hook. That is the mechanism that lets a Docker-managed chain, a ufw-managed chain and your own chain all attach to the forward hook without any of them knowing about the others.

## Handles: the answer to "delete just that one rule"

iptables identifies a rule by its position in the chain, which changes every time you insert something above it. nftables gives each rule a stable handle:

```
j@llmbits:~$ sudo nft -a list chain inet labfw output
table inet labfw {
	chain output { # handle 1
		type filter hook output priority filter; policy accept;
		oifname "ens224" ip daddr 10.77.3.10 tcp dport 5201 counter packets 1 bytes 60 reject with icmp port-unreachable # handle 3
	}
}

j@llmbits:~$ sudo nft delete rule inet labfw output handle 3
j@llmbits:~$ sudo nft list chain inet labfw output
table inet labfw {
	chain output {
		type filter hook output priority filter; policy accept;
	}
}
```

`-a` is the flag you will type most often. The handle stays attached to that rule for its lifetime, so a script can add a rule, record the handle and remove exactly that rule later without any risk of deleting whatever moved into position 3 in the meantime. Anyone who has written an automation that does `iptables -D CHAIN 3` and eventually deleted the wrong rule knows what this is worth.

## Sets: the feature worth migrating for

An anonymous set puts a list inside a rule, replacing `multiport` and its arbitrary limits:

```
j@llmbits:~$ sudo nft add rule inet labfw output oifname ens224 ip daddr 10.77.3.10 tcp dport '{ 22, 80, 5201, 8080 }' counter reject

j@llmbits:~$ sudo nft list chain inet labfw output
table inet labfw {
	chain output {
		type filter hook output priority filter; policy accept;
		oifname "ens224" ip daddr 10.77.3.10 tcp dport { 22, 80, 5201, 8080 } counter packets 0 bytes 0 reject with icmp port-unreachable
	}
}

j@llmbits:~$ nc -zv -w 2 10.77.3.10 5201
nc: connect to 10.77.3.10 port 5201 (tcp) failed: Connection refused
j@llmbits:~$ nc -zv -w 2 10.77.3.10 8080
nc: connect to 10.77.3.10 port 8080 (tcp) failed: Connection refused
```

That is convenience. The real feature is the named set, which exists independently of the rules that reference it:

```
j@llmbits:~$ sudo nft flush chain inet labfw output
j@llmbits:~$ sudo nft add set inet labfw blocked_ports '{ type inet_service ; }'
j@llmbits:~$ sudo nft add rule inet labfw output oifname ens224 ip daddr 10.77.0.0/16 tcp dport @blocked_ports counter drop

j@llmbits:~$ nc -zv -w 2 10.77.3.10 5201
Connection to 10.77.3.10 5201 port [tcp/*] succeeded!
```

The rule is live but the set is empty, so nothing matches. Now add an element, and the policy changes with no rule edit at all:

```
j@llmbits:~$ sudo nft add element inet labfw blocked_ports '{ 5201 }'

j@llmbits:~$ nc -zv -w 3 10.77.3.10 5201
nc: connect to 10.77.3.10 port 5201 (tcp) timed out: Operation now in progress
j@llmbits:~$ nc -zv -w 2 10.77.3.10 8080
Connection to 10.77.3.10 8080 port [tcp/http-alt] succeeded!

j@llmbits:~$ sudo nft list set inet labfw blocked_ports
table inet labfw {
	set blocked_ports {
		type inet_service
		elements = { 5201 }
	}
}

j@llmbits:~$ sudo nft delete element inet labfw blocked_ports '{ 5201 }'
j@llmbits:~$ nc -zv -w 3 10.77.3.10 5201
Connection to 10.77.3.10 5201 port [tcp/*] succeeded!
```

Think about what that means operationally. A blocklist becomes a set of type `ipv4_addr` that a script feeds from a threat feed, and updating it is one `add element` call that never touches the ruleset. Sets can carry a `timeout`, so entries expire on their own, and a `flags interval` set accepts CIDR ranges. Matching is a hash or interval lookup rather than a linear walk, so a set with ten thousand entries costs roughly what a set with ten costs. The iptables equivalent was ipset, a separate tool with separate persistence; here it is part of the same object.

The related construct is the verdict map, which takes the lookup one step further and stores the verdict itself: `tcp dport vmap { 22 : accept, 5201 : drop }` replaces a chain of comparisons with a single lookup.

## The ruleset as a file, loaded atomically

This is how nftables is meant to be run in production. Write the whole policy as a file:

```
j@llmbits:~$ cat /tmp/labfw.nft
#!/usr/sbin/nft -f

table inet labfw {
        set allowed_ports {
                type inet_service
                elements = { 8080 }
        }

        chain lab_out {
                type filter hook output priority filter; policy accept;

                # only ever look at traffic leaving the lab NIC
                oifname != "ens224" accept
                ip daddr != 10.77.0.0/16 accept

                ct state established,related accept
                tcp dport @allowed_ports counter accept
                icmp type echo-request limit rate 2/second counter accept
                counter log prefix "labfw-drop " drop
        }
}
```

Load it in one command:

```
j@llmbits:~$ sudo nft -f /tmp/labfw.nft

j@llmbits:~$ sudo nft list ruleset
table inet labfw {
	set allowed_ports {
		type inet_service
		elements = { 8080 }
	}

	chain lab_out {
		type filter hook output priority filter; policy accept;
		oifname != "ens224" accept
		ip daddr != 10.77.0.0/16 accept
		ct state established,related accept
		tcp dport @allowed_ports counter packets 0 bytes 0 accept
		icmp type echo-request limit rate 2/second burst 5 packets counter packets 0 bytes 0 accept
		counter packets 0 bytes 0 log prefix "labfw-drop " drop
	}
}
```

Several things in that file are worth calling out. The two `!=` rules at the top are a scoping guard: anything not leaving `ens224` for the lab range is accepted immediately, which means the policy below can never touch the management path no matter what you add to it. Comments are supported in the file but do not survive the load, as the listing above shows, so keep the file in version control and treat the loaded ruleset as build output rather than the source of truth. And `counter log prefix "..." drop` is three statements in one rule, where iptables needed two separate rules because `LOG` was a non-terminating target.

Testing it, with the counters read after an HTTP request, a blocked connection attempt and the ping test from the next section:

```
j@llmbits:~$ curl -sS -m 5 -o /dev/null -w 'HTTP %{http_code}\n' http://10.77.3.10:8080/
HTTP 200
j@llmbits:~$ nc -zv -w 3 10.77.3.10 5201
nc: connect to 10.77.3.10 port 5201 (tcp) timed out: Operation now in progress

j@llmbits:~$ sudo nft list chain inet labfw lab_out
	chain lab_out {
		type filter hook output priority filter; policy accept;
		oifname != "ens224" accept
		ip daddr != 10.77.0.0/16 accept
		ct state established,related accept
		tcp dport @allowed_ports counter packets 1 bytes 60 accept
		icmp type echo-request limit rate 2/second burst 5 packets counter packets 1 bytes 84 accept
		counter packets 3 bytes 180 log prefix "labfw-drop " drop
	}
```

And the log statement writes to the kernel ring buffer exactly like the iptables LOG target:

```
j@llmbits:~$ sudo dmesg | grep labfw-drop | tail -3
[240564.108550] labfw-drop IN= OUT=ens224 SRC=10.77.0.100 DST=10.77.3.10 LEN=60 TOS=0x00 PREC=0x00 TTL=64 ID=27867 DF PROTO=TCP SPT=41692 DPT=5201 WINDOW=64240 RES=0x00 SYN URGP=0
```

## An honest result: the rate limiter that did not limit

The ping test in that run came back clean, and it should not have:

```
j@llmbits:~$ sudo ping -c 10 -i 0.1 -W 1 10.77.3.10 | tail -3
10 packets transmitted, 10 received, 0% packet loss, time 906ms
rtt min/avg/max/mdev = 4.614/5.402/6.950/0.584 ms
```

Ten pings in under a second against a two-per-second limiter, and none were dropped. The counter listing above is the explanation: the ICMP rule matched exactly one packet across the whole test. The other nine never reached it, and the only rule above that could have accepted them is `ct state established,related accept`, because connection tracking treats an ICMP echo exchange as a flow and marks everything after the first request as `ESTABLISHED`. That rule carries no `counter`, which is a good argument for putting one on every rule you might ever need to reason about.

This is the ordering trap that catches everyone who puts a stateful accept at the top of a chain and then tries to rate limit below it. The conntrack rule is the right first rule for TCP performance, and it silently defeats any per-packet limiter placed after it. If you want the limit to apply, match on `ct state new` in the limiter, or put the limiter above the stateful accept. Real output beats a diagram here, which is exactly why we run these in the lab rather than reasoning about them.

## Atomic loading, demonstrated

Break the file deliberately, referencing a set that does not exist, and reload:

```
j@llmbits:~$ sed -i 's/tcp dport @allowed_ports/tcp dport @no_such_set/' /tmp/labfw.nft
j@llmbits:~$ sudo nft -f /tmp/labfw.nft
/tmp/labfw.nft:17:27-38: Error: No such file or directory
                tcp dport @no_such_set counter accept
                          ^^^^^^^^^^^^

j@llmbits:~$ sudo nft list chain inet labfw lab_out
	chain lab_out {
		type filter hook output priority filter; policy accept;
		oifname != "ens224" accept
		ip daddr != 10.77.0.0/16 accept
		ct state established,related accept
		tcp dport @allowed_ports counter packets 1 bytes 60 accept
		icmp type echo-request limit rate 2/second burst 5 packets counter packets 1 bytes 84 accept
		counter packets 3 bytes 180 log prefix "labfw-drop " drop
	}
```

The load failed, and the running ruleset is untouched, counters and all. `iptables-restore` commits one table at a time, so a bad line aborts that table but can still leave earlier tables in the same file applied, which on a policy spanning filter, nat and mangle is a genuinely awkward state to be in. nftables scopes the transaction to the whole file. The error message even points at the column. This is the single strongest argument for driving nftables from a file instead of a sequence of `add rule` commands.

Backup is the mirror image:

```
j@llmbits:~$ sudo nft list ruleset > /tmp/ruleset.bak
```

That output is itself a valid input file, so `nft -f /tmp/ruleset.bak` restores it. On Debian and Ubuntu the persistent path is `/etc/nftables.conf`, loaded at boot by the `nftables.service` unit, and like iptables nothing survives a reboot unless something reloads it.

## Teardown, and a warning about half-cleanups

```
j@llmbits:~$ sudo nft delete table inet labfw
j@llmbits:~$ sudo nft list ruleset
table ip filter {
	chain INPUT {
		type filter hook input priority filter; policy accept;
	}

	chain OUTPUT {
		type filter hook output priority filter; policy accept;
	}
}
table ip nat {
	chain OUTPUT {
		type nat hook output priority dstnat; policy accept;
	}
}

j@llmbits:~$ nc -zv -w 3 10.77.3.10 5201
Connection to 10.77.3.10 5201 port [tcp/*] succeeded!
```

Note what is left behind: the `ip filter` and `ip nat` tables are still there, empty, because those belong to the iptables compatibility layer and were created the moment somebody ran an `iptables` command earlier in the session. Deleting your own table does not touch them, which is exactly the separation you want.

One command removes the table, its chains, its sets and every rule in it. That is the practical benefit of owning your own table: cleanup is atomic and total, where iptables needs a flush, a chain delete and a policy reset per table per address family.

The commands you will actually use day to day are a short list:

`nft list ruleset`

Everything, in a format you can save and reload.

`nft -a list chain <fam> <table> <chain>`

One chain with handles, which is what you need before deleting anything.

`nft -f <file>`

Load a whole policy atomically. The production path.

`nft add/delete element`

Change what a policy matches without changing the policy.

`nft delete table <fam> <name>`

Remove an entire policy in one step.

`nft monitor`

Watch ruleset changes live. Invaluable when something else on the box is writing rules.

## Should you migrate?

Honest answer: not urgently, and not by rewriting a working policy for its own sake. Your iptables rules already run on this engine. The cases where it pays are specific.

Migrate when your policy has a list that changes: blocklists, allowlists of management sources, per-customer ranges. Named sets turn a rule-editing problem into a data problem. Migrate when you maintain parallel IPv4 and IPv6 rulesets, because an `inet` table halves that work and eliminates the drift where someone adds a rule to one family and forgets the other. Migrate when a partial rule load would be an outage, because atomic loading is a real safety property. And migrate when you are automating, because handles give you a stable identifier that positions never did.

Stay where you are when the policy is twenty static rules that have not changed in two years. There is no prize for the rewrite, and the compatibility layer is maintained.

## Key takeaways

- Your iptables rules are already nftables rules. `iptables -A ...` followed by `nft list ruleset` shows the same rule in nftables syntax.
- Never add native `nft` rules to a table that nftables labels *managed by iptables-nft*. Create your own table.
- `iptables-translate` (in `/usr/sbin`) converts a rule without applying it, and `iptables-restore-translate` converts a whole saved policy.
- An `inet` family table filters IPv4 and IPv6 with one set of rules.
- Counters are opt-in. Write `counter` in any rule you intend to troubleshoot.
- `nft -a` shows handles, which are stable rule identifiers. Delete by handle, never by position.
- Named sets change what a policy matches without editing the policy, support timeouts and CIDR intervals, and match in constant time.
- A ruleset file loaded with `nft -f` is all-or-nothing. A syntax error leaves the running ruleset completely untouched.
- A `ct state established,related accept` rule placed above a rate limiter will absorb the traffic you meant to limit. Match `ct state new` in the limiter instead.
- `nft delete table inet <name>` removes chains, sets, rules and all in one atomic step.

nftables is the engine, [iptables](https://www.pinglabz.com/iptables-linux/) is the syntax most of the world still writes, and [ufw](https://www.pinglabz.com/ufw-linux/) is the front end for the days you need one port open and nothing more. All three sit in the firewall section of the [complete guide to Linux networking commands](https://www.pinglabz.com/linux-networking-commands/), alongside the capture and socket tools you will reach for when a rule does not behave the way you expected.