Check what you have

# See if nftables is in use
sudo nft list ruleset

# On Debian / Ubuntu, the default iptables command is a thin wrapper
# over nftables (via iptables-nft); your existing iptables rules already
# live in the nftables kernel layer.
which iptables
update-alternatives --display iptables   # typically iptables-nft, not iptables-legacy

The minimum-viable host firewall

# /etc/nftables.conf
#!/usr/sbin/nft -f

flush ruleset

table inet filter {
    # Sets for re-use
    set blocklist_v4 {
        type ipv4_addr
        flags interval
    }

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

        # Always allow loopback + established connections
        iif lo accept
        ct state {established, related} accept

        # Drop invalid + blocklisted
        ct state invalid drop
        ip saddr @blocklist_v4 drop

        # Allow ICMP / ICMPv6 (rate-limited)
        ip protocol icmp icmp type {echo-request, destination-unreachable, time-exceeded} limit rate 5/second accept
        ip6 nexthdr ipv6-icmp accept

        # SSH only from the LAN
        ip saddr 192.168.1.0/24 tcp dport 22 accept

        # HTTPS from anywhere
        tcp dport {80, 443} accept

        # Log + drop everything else
        log prefix "nft-input-drop: " limit rate 5/minute
        counter
    }

    chain forward {
        type filter hook forward priority filter; policy drop;
        # Add rules here if this host routes
    }

    chain output {
        type filter hook output priority filter; policy accept;
        # Optionally restrict egress
    }
}
# Load + enable
sudo nft -f /etc/nftables.conf
sudo systemctl enable --now nftables

# Inspect
sudo nft list ruleset

The key concepts

  • table — a container; one per address family (or inet for both v4 and v6).
  • chain — an ordered rule list, attached to a hook (input, output, forward, prerouting, postrouting).
  • rule — a match + action.
  • set — named list of values (IPs, ports, MACs) with O(1) lookup. Way faster than iptables' multi-rule equivalent.
  • map — a set with values (key → value); enables verdict maps for compact L4 dispatch.

Sets: the killer feature

# Define
set blocklist {
    type ipv4_addr
    flags interval, timeout
}

# Add elements (with optional TTL)
add element inet filter blocklist { 198.51.100.42 timeout 1h }
add element inet filter blocklist { 203.0.113.0/24 }

# Reference in a rule
ip saddr @blocklist drop

Sets support intervals (CIDR ranges) and per-element timeouts. Useful for ephemeral block lists, fail2ban-style temporary bans without rewriting the whole ruleset.

Atomic replacement

# Compose a new ruleset in a file, swap in atomically
sudo nft -c -f /etc/nftables.conf      # -c = check only, validates syntax
sudo nft -f /etc/nftables.conf         # replaces in one transaction

Either the entire new ruleset is loaded or none of it is. No "half-applied" state where the SSH rule is loaded but the firewall-drop default isn't — the kind of bug that locks you out of a remote host. iptables didn't have this safety; nftables makes it the default.

NAT (for a router-shaped host)

table ip nat {
    chain prerouting {
        type nat hook prerouting priority dstnat;

        # Port forward 80/443 to an internal host
        iif "eth0" tcp dport {80, 443} dnat to 192.168.1.20
    }

    chain postrouting {
        type nat hook postrouting priority srcnat;

        # Masquerade LAN traffic going out the WAN
        oif "eth0" masquerade
    }
}

Verdict maps for L4 dispatch

# A compact way to express "different verdicts per dest port"
chain input {
    type filter hook input priority filter; policy drop;

    iif lo accept
    ct state {established, related} accept

    # Map: port -> action
    tcp dport vmap {
        22:   jump ssh_chain,
        80:   accept,
        443:  accept,
        9100: jump prometheus_chain
    }
}

chain ssh_chain {
    ip saddr {192.168.1.0/24, 10.0.5.0/24} accept
    log prefix "nft-ssh-deny: " counter drop
}

chain prometheus_chain {
    ip saddr 10.0.6.50 accept            # only the prometheus server
    counter drop
}

Vmaps are O(1) per packet vs the O(n) of a chain of "if dport == X jump Y" rules. Critical for high-throughput firewalls with many port-specific policies.

Connection tracking and rate limiting

# Per-IP connection limit (DDoS basic mitigation)
chain input {
    tcp flags syn tcp dport 80 ct count over 100 reject

    # Rate-limit SYN floods
    tcp flags & (fin|syn|rst|ack) == syn limit rate over 50/second drop
}

Logging

# Log to dmesg
log prefix "nft-drop: " counter drop

# Log to nflog (more efficient; consumed by ulogd2)
log prefix "nft-drop: " group 0 counter drop

# View
journalctl -k -f | grep nft-drop

Monitor what's actually hitting your rules

# Add 'counter' to any rule to count packets + bytes
tcp dport 22 accept counter

# View counts
sudo nft list ruleset | grep -A 1 counter
sudo nft reset counter inet filter ssh    # reset specific counter

Useful for "is this rule actually catching anything?" debugging without sniffing the wire.

Convert old iptables rules

# Convert
sudo iptables-save > /tmp/old.rules
sudo iptables-restore-translate -f /tmp/old.rules > /tmp/new.nft

# Review (it's mostly mechanical, with quirks)
cat /tmp/new.nft

# Test in a non-flushed mode
sudo nft -c -f /tmp/new.nft

The translation is decent for simple rule sets, less clean for elaborate ones. Treat output as a starting point; rewrite for clarity if needed.

UFW / firewalld: the friendly wrappers

If you don't want to write nftables directly:

  • UFW (Ubuntu's default) — ufw allow ssh, etc. Backed by nftables in modern Debian/Ubuntu. Smaller feature surface.
  • firewalld (RHEL / Fedora) — zone-based; well-integrated with NetworkManager. Backed by nftables too.

Both are fine if their abstraction fits. For "I have a specific policy and want the full nftables expressiveness," drop down to nftables directly.

For Kubernetes / containers

Don't manage host nftables to expose container ports; the container runtime (Docker / containerd) and Kubernetes (kube-proxy, Cilium, Calico) manage their own iptables/nftables layers. Conflicting host rules cause confusing behavior.

For host-level egress restriction plus Kubernetes — use NetworkPolicies + Cilium's policy engine (see that tutorial) inside the cluster, leave host nftables for SSH / management ports only.

The big mental shift from iptables

  • iptables had separate filter / nat / mangle / raw tables — each with their own chain layout. nftables has one table system; you create the chains.
  • iptables matched parameters with flags (-p tcp -m multiport --dports 80,443); nftables uses a cleaner predicate-style syntax (tcp dport {80, 443}).
  • iptables' policy was per-chain; nftables makes it explicit on the chain definition.
  • iptables ruleset updates were sequential (one rule at a time); nftables is atomic.

The result: significantly less code for the same policy, faster lookups at scale, no "lock yourself out by typo" footgun.