Here's something most people don't know: on Debian 10+, Ubuntu 20.04+, and RHEL 8+, when you run iptables commands you're not talking to the old iptables kernel backend anymore.

You're talking to nftables, through a compatibility layer called iptables-nft.

The replacement already happened. You just might not have noticed.

This post is about using nftables directly — without the compatibility layer, without translating from iptables muscle memory. The native interface is cleaner, more powerful, and the way things are heading.


What Changed and Why

The old iptables kernel backend (called xtables) had real problems:

  • IPv4 and IPv6 were separate subsystems — iptables and ip6tables. Two rulesets to maintain in sync.
  • No atomic updates. You flush, you're briefly unprotected, you reload. That's a real window.
  • Rules were opaque. You'd iptables -L and get a wall of text with no structure.
  • No native sets. Blocking 500 IPs meant 500 separate rules.

nftables was merged into the Linux kernel in 3.13 (2014) to fix all of this. One tool handles IPv4, IPv6, ARP, and bridge filtering. Rulesets are structured files. Updates are atomic. Sets are a first-class feature.

The kernel team has stopped adding features to the old xtables backend. New features — especially around connection tracking — only land in nftables. iptables is in maintenance mode.


The New Mental Model

nftables keeps the same core concepts — tables, chains, rules — but gives you control over things iptables hardcoded.

In iptables, filter, nat, mangle are fixed tables with fixed purposes. In nftables, you create your own tables and name them what you want. You define chains inside them and specify which hooks they attach to.

A chain in nftables looks like this:

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

type filter — this is a filtering chain (vs nat, route).
hook input — attach to the INPUT hook in the network stack.
priority 0 — where in the hook order to run (lower = earlier). Standard value for filter chains.
policy drop — default action if no rule matches.

This is more verbose than iptables, but it's explicit. You can see exactly what every chain does just by reading it.


Your First nftables Ruleset

Install the tools:

apt install nftables
systemctl enable --now nftables

Check what's loaded:

nft list ruleset

On a fresh system this is empty. Let's build a real server ruleset.

Create /etc/nftables.conf:

#!/usr/sbin/nft -f

flush ruleset

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

        # Allow established and related connections
        ct state established,related accept

        # Allow loopback
        iif lo accept

        # Allow ICMP (ping) with rate limit
        ip protocol icmp icmp type echo-request limit rate 1/second accept
        ip6 nexthdr icmpv6 icmpv6 type echo-request limit rate 1/second accept

        # Allow SSH
        tcp dport 22 accept

        # Allow HTTP and HTTPS
        tcp dport { 80, 443 } accept

        # Log and drop everything else
        limit rate 5/minute log prefix "nft drop: " flags all
    }

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

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

Notice table inet filter. The inet family handles both IPv4 and IPv6. One table, both protocols. No more duplicate rulesets.

Notice tcp dport { 80, 443 }. That's a set literal — match multiple values in one rule. iptables needed a separate rule for each port.

Apply it:

nft -f /etc/nftables.conf
nft list ruleset

WireGuard Rules

Continuing from the WireGuard post — add these inside the input chain:

# Allow WireGuard
udp dport 51820 accept
iif wg0 accept

And if you're forwarding between peers, add a forward chain:

chain forward {
    type filter hook forward priority 0; policy drop;
    
    iif wg0 accept
    oif wg0 accept
    ct state established,related accept
}

Sets: The Feature iptables Never Had

This is where nftables earns its keep. A set is a collection of addresses, ports, or other values that you can match against in a single rule.

Inline sets (anonymous, used once):

tcp dport { 22, 80, 443, 8080 } accept
ip saddr { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 } accept

Named sets (reusable, can be updated without reloading):

table inet filter {
    set trusted_ips {
        type ipv4_addr
        flags interval
        elements = { 10.0.0.0/8, 192.168.1.0/24 }
    }

    chain input {
        type filter hook input priority 0; policy drop;
        
        ip saddr @trusted_ips accept
    }
}

Add to a named set at runtime without reloading the whole ruleset:

nft add element inet filter trusted_ips { 203.0.113.5 }

Remove from it:

nft delete element inet filter trusted_ips { 203.0.113.5 }

In iptables, blocking 500 IPs was 500 rules — and each packet got checked against all 500. With a set, it's one rule and the kernel uses a hash table lookup. Massively faster.


NAT in nftables

Port forwarding and masquerading use a separate table:

table ip nat {
    chain prerouting {
        type nat hook prerouting priority -100;
        
        # Forward external port 8080 to internal host
        tcp dport 8080 dnat to 192.168.1.10:80
    }

    chain postrouting {
        type nat hook postrouting priority 100;
        
        # Masquerade outbound traffic on eth0
        oif eth0 masquerade
    }
}

Note this uses table ip (IPv4 only) for NAT — kernel limitation, NAT doesn't apply to inet family for v6 separately. For IPv6 you'd use table ip6 nat.


Rate Limiting

nftables has clean rate limiting syntax:

# Limit new SSH connections to 3 per minute per IP
tcp dport 22 ct state new meter ssh_limit { ip saddr limit rate 3/minute } accept
tcp dport 22 ct state new drop

The meter keyword creates a per-element rate limiter — each source IP gets its own counter.

In iptables this required the recent module and two separate rules. Here it's one.


Common Operations

List everything:

nft list ruleset

List a specific table:

nft list table inet filter

Add a rule at runtime:

nft add rule inet filter input tcp dport 8080 accept

Delete a rule:

# List with handles first
nft list table inet filter -a

# Delete by handle number
nft delete rule inet filter input handle 5

Flush and reload from file:

nft -f /etc/nftables.conf

Test a config file without applying:

nft -c -f /etc/nftables.conf

The -c flag checks syntax without making changes. Use it before applying any ruleset in production.


Migrating from iptables

If you have existing iptables rules, there's a translation tool:

# Translate a single rule
iptables-translate -A INPUT -p tcp --dport 22 -j ACCEPT
# Output: nft add rule ip filter INPUT tcp dport 22 counter accept

# Translate a full saved ruleset
iptables-restore-translate -f /etc/iptables/rules.v4

The output isn't always perfect — especially for complex rules — but it gets you 80% of the way there.


Persistence

The nftables systemd service loads /etc/nftables.conf at boot. That's it. No iptables-save, no iptables-persistent package needed.

Edit /etc/nftables.conf, test with nft -c -f /etc/nftables.conf, apply with nft -f /etc/nftables.conf, and the service handles the rest.

systemctl enable nftables    # load at boot
systemctl start nftables     # load now
systemctl reload nftables    # reload config file

Troubleshooting

Rules not matching

Check the order. Like iptables, nftables evaluates rules top to bottom and stops at first match. Use nft list ruleset -a to see handle numbers and rule order.

See packet counters

nft list ruleset -a

Each rule shows packets and bytes matched. Watch which rules are accumulating traffic.

Add counters to a specific rule for debugging

tcp dport 22 counter accept

The counter keyword is optional in nftables — rules don't count by default (unlike iptables). Add it explicitly where you want visibility.

Trace a packet through the ruleset

# Enable tracing
nft add rule inet filter input meta nftrace set 1

# Watch the trace
nft monitor trace

This is far more powerful than iptables debugging — you see exactly which rule each packet hits, in order.


Honest Verdict

nftables is unambiguously better than iptables. Cleaner syntax, unified IPv4/IPv6, atomic updates, proper sets, better debugging.

If you're setting up a new machine today, use nftables natively.

The only reason to stay with iptables commands is legacy infrastructure or tooling that generates iptables rules. Even then, iptables-nft means you're probably already running on the nftables backend without knowing it.

The transition isn't painful. The iptables mental model transfers directly — tables, chains, rules, policies. The syntax just looks different.

Spend an afternoon with it and it'll feel natural.


Go Try It

nft list ruleset

If you've been using ufw or iptables, this will show you what's actually loaded underneath.

Then try writing one rule natively — just one — and apply it:

nft add rule inet filter input tcp dport 9999 accept
nft list ruleset

Watch it appear. Then delete it:

nft list ruleset -a    # get the handle
nft delete rule inet filter input handle <N>

That feedback loop — write, apply, verify, delete — is how you build the instinct.


Compiled by AI. Proofread by caffeine. ☕