nftables Firewall Hardening Guide: Zero-Trust Rulesets & Automated Threat Response

A hands-on guide to building production-grade, zero-trust firewall architectures with nftables on Linux. Covers hardened rulesets, threat intelligence sets, SYN flood protection, per-IP rate limiting, Fail2Ban integration, and Ansible deployment automation.

Introduction: Why nftables Is the Firewall Framework You Should Be Running in 2026

If you're still writing iptables rules on production Linux systems, it's time for a frank conversation. The kernel community has been actively moving away from iptables since 2014, and as of 2026, every major distribution — Debian 12+, Fedora 39+, RHEL 9+, Ubuntu 24.04+, and Arch — ships nftables as the default packet filtering framework. Yes, the iptables compatibility layer still exists, but let's call it what it is: a shim that translates legacy syntax into nftables bytecode behind the scenes.

So why does this matter to you?

nftables delivers a unified syntax for IPv4, IPv6, ARP, and bridge filtering in a single rule language. It replaces six separate iptables binaries (iptables, ip6tables, arptables, ebtables, and their -save/-restore counterparts) with one tool: nft. Under the hood, it compiles rules into a register-based virtual machine in the kernel, achieving roughly 30% lower CPU utilization compared to iptables under equivalent traffic loads, according to benchmarks by the Linux Foundation in 2024. That's not a marginal improvement — on busy servers, it's the difference between headroom and scrambling to add capacity.

But raw performance is only part of the story. nftables introduces first-class support for sets, maps, concatenations, and stateful objects that make it possible to build genuinely sophisticated firewall architectures — rate-limited per-IP connection tracking, geolocation-based blocking, dynamic ban lists, and SYN proxy configurations — all expressed declaratively in a single configuration file. Honestly, once you've worked with these features, going back to iptables feels painful.

This guide is built for Linux administrators and security engineers who want to move beyond basic port filtering and build a production-grade, zero-trust firewall stack using nftables. We'll cover the core architecture, build a hardened baseline ruleset from scratch, implement advanced features like sets and maps for threat intelligence integration, configure connection tracking and SYN flood protection, integrate with Fail2Ban for automated response, and finally automate everything with Ansible. Let's dive in.

Understanding the nftables Architecture

Tables, Chains, and Rules: The Building Blocks

Before writing a single rule, you need to understand how nftables organizes its filtering logic. The architecture is fundamentally different from iptables, and trying to map one-to-one will lead you down a frustrating path of poorly structured rulesets.

In nftables, everything starts with tables. A table is a namespace that groups chains and other objects. Unlike iptables — which has hardcoded tables like filter, nat, mangle, and raw — nftables tables are entirely user-defined. You create them with whatever name makes sense for your environment:

# Create a table for the inet (IPv4+IPv6) family
nft add table inet firewall

The inet family is the key innovation here. It handles both IPv4 and IPv6 traffic in a single ruleset, which eliminates the need for parallel ip and ip6 tables. If you've ever forgotten to update your ip6tables rules after changing iptables (we've all been there), you'll appreciate this immediately.

Chains live inside tables and define hook points where packets get inspected. There are two types: base chains that attach to netfilter hooks (input, output, forward, prerouting, postrouting), and regular chains that act as subroutines you can jump to from base chains:

# Base chain attached to the input hook
nft add chain inet firewall input { type filter hook input priority 0 \; policy drop \; }

# Regular chain for SSH-specific rules
nft add chain inet firewall ssh_rules

Notice the policy drop declaration. This is the cornerstone of zero-trust firewall design: deny everything by default, then explicitly permit only the traffic you need. Every base chain in your production ruleset should have a drop policy. No exceptions.

Rules are the individual match-and-action statements within chains. They're evaluated in order, and the first matching rule determines the packet's fate (unless the rule uses a non-terminal verdict like log):

# Accept established and related connections
nft add rule inet firewall input ct state established,related accept

# Accept SSH from a specific subnet
nft add rule inet firewall input ip saddr 10.0.1.0/24 tcp dport 22 accept

# Log and drop everything else (implicit via chain policy)

The Atomic Ruleset Model

One of nftables' most important security properties — and something that doesn't get enough attention — is its atomic ruleset replacement. When you load a configuration file, the entire ruleset is applied as a single Netlink transaction. Either all rules load successfully, or none do. This eliminates the race condition window that existed with iptables-restore, where a partially loaded ruleset could leave the system briefly exposed.

# Load an entire ruleset atomically
nft -f /etc/nftables.conf

# Validate syntax without applying
nft -c -f /etc/nftables.conf

The -c (check) flag is invaluable for CI/CD pipelines. You can lint your firewall configuration in a pre-deployment step and fail the pipeline if the syntax is invalid, without ever touching a running system. I've caught more misplaced semicolons this way than I'd like to admit.

Building a Zero-Trust Baseline Ruleset

Let's build a production-grade nftables configuration from scratch. This ruleset implements a strict deny-by-default posture across input, forward, and output chains — because true zero-trust means controlling egress traffic too, not just ingress.

#!/usr/sbin/nft -f

# Flush existing rules for a clean slate
flush ruleset

# Define variables for maintainability
define ADMIN_NET   = { 10.0.1.0/24, 192.168.50.0/24 }
define DNS_SERVERS = { 1.1.1.1, 8.8.8.8, 2606:4700:4700::1111 }
define NTP_SERVERS = { 162.159.200.1, 216.239.35.0 }
define SSH_PORT    = 22
define WEB_PORTS   = { 80, 443 }

table inet firewall {

    # ── Sets for dynamic threat management ──────────────────
    set blocklist_v4 {
        type ipv4_addr
        flags timeout
        timeout 24h
        comment "Dynamic IPv4 blocklist with auto-expiry"
    }

    set blocklist_v6 {
        type ipv6_addr
        flags timeout
        timeout 24h
        comment "Dynamic IPv6 blocklist with auto-expiry"
    }

    set rate_limited_v4 {
        type ipv4_addr
        flags dynamic, timeout
        timeout 5m
    }

    # ── Input chain: deny-by-default ────────────────────────
    chain input {
        type filter hook input priority 0; policy drop;

        # Drop traffic from blocklists immediately
        ip saddr @blocklist_v4 counter drop
        ip6 saddr @blocklist_v6 counter drop

        # Accept loopback — required for local services
        iifname "lo" accept

        # Drop invalid connections early
        ct state invalid counter drop

        # Accept established/related — stateful tracking
        ct state established,related accept

        # ICMPv4: allow ping but rate-limit
        ip protocol icmp icmp type echo-request \
            limit rate 5/second burst 10 packets accept

        # ICMPv6: essential for IPv6 operation
        ip6 nexthdr icmpv6 icmpv6 type {
            echo-request,
            nd-neighbor-solicit,
            nd-neighbor-advert,
            nd-router-solicit,
            nd-router-advert,
            mld-listener-query
        } accept

        # SSH: restricted to admin networks with rate limiting
        tcp dport $SSH_PORT ip saddr $ADMIN_NET \
            ct state new accept

        # Web services: open to the world
        tcp dport $WEB_PORTS ct state new accept

        # Log dropped packets for forensic analysis
        limit rate 10/minute burst 20 packets \
            log prefix "[nft-input-drop] " level info
    }

    # ── Forward chain: deny-by-default ──────────────────────
    chain forward {
        type filter hook forward priority 0; policy drop;

        ct state invalid counter drop
        ct state established,related accept

        # Add forwarding rules here for routed networks
        # Example: allow containers to reach the internet
        # iifname "docker0" oifname "eth0" accept

        limit rate 5/minute burst 10 packets \
            log prefix "[nft-forward-drop] " level info
    }

    # ── Output chain: restrict egress ───────────────────────
    chain output {
        type filter hook output priority 0; policy drop;

        # Loopback
        oifname "lo" accept

        # Stateful return traffic
        ct state established,related accept

        # DNS: only to trusted resolvers
        ip daddr $DNS_SERVERS udp dport 53 accept
        ip daddr $DNS_SERVERS tcp dport 53 accept
        ip6 daddr $DNS_SERVERS udp dport 53 accept
        ip6 daddr $DNS_SERVERS tcp dport 53 accept

        # NTP: only to trusted time servers
        ip daddr $NTP_SERVERS udp dport 123 accept

        # HTTP/HTTPS outbound: for package updates
        tcp dport $WEB_PORTS ct state new accept

        # SMTP outbound (if mail relay)
        # tcp dport { 25, 587 } ct state new accept

        # Log blocked egress — critical for detecting exfiltration
        limit rate 10/minute burst 20 packets \
            log prefix "[nft-output-drop] " level warn
    }
}

This ruleset enforces several critical security principles that are worth calling out explicitly.

First, deny-by-default on all chains — including output. Most firewall guides skip egress filtering entirely, but think about it: a compromised process that can make arbitrary outbound connections can exfiltrate data, download second-stage payloads, or establish reverse shells. By restricting outbound traffic to known destinations and ports, you significantly reduce the blast radius of a compromise.

Second, connection tracking state enforcement. The ct state invalid drop rule discards packets that don't belong to any known connection — a common indicator of scanning, spoofing, or malformed attack traffic. The ct state established,related accept rule ensures that once a connection is legitimately established, its subsequent packets flow without hitting every rule in the chain. That's a performance win too.

Third, rate-limited logging. Logging every dropped packet sounds thorough until it fills your disks and creates a denial-of-service condition on your logging infrastructure. The limit rate 10/minute clause captures enough forensic data to detect attacks without overwhelming your system.

Advanced Features: Sets, Maps, and Dynamic Threat Intelligence

Named Sets for IP Reputation

This is where nftables really starts to shine. Named sets are kernel-space data structures that allow O(1) lookups against potentially millions of addresses. This replaces the old ipset + iptables pattern with a native, integrated solution — and it's noticeably cleaner to manage.

You can populate sets from external threat intelligence feeds. Here's a practical approach using a script that pulls blocklists and loads them atomically:

#!/bin/bash
# /usr/local/bin/update-blocklists.sh
# Fetch and load IP reputation data into nftables sets

set -euo pipefail

BLOCKLIST_DIR="/var/lib/nftables/blocklists"
mkdir -p "$BLOCKLIST_DIR"

# Download abuse.ch Feodo Tracker botnet C2 IPs
curl -sS https://feodotracker.abuse.ch/downloads/ipblocklist.txt \
    | grep -v "^#" | grep -v "^$" \
    > "$BLOCKLIST_DIR/feodo.txt"

# Download Spamhaus DROP list
curl -sS https://www.spamhaus.org/drop/drop.txt \
    | awk '{print $1}' | grep -v "^;" \
    > "$BLOCKLIST_DIR/spamhaus-drop.txt"

# Flush existing set entries and reload
nft flush set inet firewall blocklist_v4

# Build a batch command for atomic loading
{
    echo "flush set inet firewall blocklist_v4"
    while IFS= read -r ip; do
        [[ -z "$ip" || "$ip" == \#* ]] && continue
        echo "add element inet firewall blocklist_v4 { $ip timeout 24h }"
    done < <(cat "$BLOCKLIST_DIR/feodo.txt" "$BLOCKLIST_DIR/spamhaus-drop.txt")
} > /tmp/nft-blocklist-update.nft

# Apply atomically
nft -f /tmp/nft-blocklist-update.nft
rm -f /tmp/nft-blocklist-update.nft

logger -t nft-blocklist "Blocklist updated: $(nft list set inet firewall blocklist_v4 | grep -c timeout) entries"

Schedule this with a systemd timer or cron to run every 4–6 hours. The timeout flag ensures stale entries automatically expire, so if a threat feed removes an IP, your firewall will eventually unblock it even without an explicit removal. It's a nice self-cleaning mechanism.

Verdict Maps for Port-to-Service Routing

Verdict maps let you combine matching and action selection into a single lookup, replacing chains of if-then-else rules with what's essentially a dictionary lookup:

table inet firewall {
    map svc_policy {
        type inet_service : verdict
        elements = {
            22  : jump ssh_rules,
            80  : jump web_rules,
            443 : jump web_rules,
            53  : jump dns_rules,
        }
    }

    chain input {
        type filter hook input priority 0; policy drop;
        ct state established,related accept
        tcp dport vmap @svc_policy
    }

    chain ssh_rules {
        ip saddr 10.0.1.0/24 accept
        limit rate 3/minute burst 5 packets log prefix "[ssh-brute] " drop
    }

    chain web_rules {
        ct state new accept
    }

    chain dns_rules {
        ip saddr { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 } accept
    }
}

This pattern scales far better than linear rule evaluation. When you have dozens of services, the map lookup is constant-time rather than O(n), and the ruleset stays readable and maintainable. That's a rare combination in firewall configuration.

Concatenated Sets for Multi-Dimensional Matching

Concatenations allow you to match on multiple fields simultaneously in a single set lookup. This is incredibly useful for access control matrices — for example, allowing specific source IPs to reach specific destination ports:

set acl {
    type ipv4_addr . inet_service
    elements = {
        10.0.1.5    . 5432,    # DBA workstation -> PostgreSQL
        10.0.1.10   . 6379,    # App server -> Redis
        10.0.2.0/24 . 8080,    # Dev network -> staging app
    }
}

chain input {
    type filter hook input priority 0; policy drop;
    ct state established,related accept
    ip saddr . tcp dport @acl accept
}

Without concatenated sets, you'd need a separate rule for each IP-port combination. With them, the kernel performs a single hash lookup that matches both fields simultaneously. On a system with dozens of service-to-IP mappings, this isn't just elegant — it's a meaningful performance improvement.

SYN Flood Protection with nftables Synproxy

SYN flood attacks remain one of the most common DDoS vectors, and nftables includes a built-in synproxy mechanism that handles the TCP three-way handshake in kernel space before ever forwarding the connection to your application. For high-traffic web servers and any Internet-facing service, this is a must-have.

The synproxy works by intercepting SYN packets, completing the handshake using SYN cookies, and only forwarding the connection to the backend once the client has proven it's a legitimate TCP speaker. Here's how to configure it:

table inet firewall {
    # Synproxy object for web traffic
    synproxy https-synproxy {
        mss 1460
        wscale 7
        timestamp sack-perm
    }

    chain prerouting {
        type filter hook prerouting priority raw; policy accept;

        # Disable connection tracking for SYN packets to web ports
        # so synproxy can handle them
        tcp dport { 80, 443 } tcp flags syn notrack
    }

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

        ct state established,related accept
        ct state invalid drop

        # Hand off untracked SYN packets to synproxy
        tcp dport { 80, 443 } ct state untracked \
            synproxy name https-synproxy

        # Remaining rules...
        ct state new tcp dport { 80, 443 } accept
    }
}

Important caveats here: Synproxy requires that both TCP syncookies and TCP timestamps are enabled at the kernel level. You also need to disable the conntrack loose tracking option — otherwise the final ACK from the handshake gets classified as INVALID and dropped, which is exactly the kind of subtle bug that'll have you pulling your hair out for hours:

# Required kernel parameters for synproxy
sysctl -w net.ipv4.tcp_syncookies=1
sysctl -w net.ipv4.tcp_timestamps=1
sysctl -w net.netfilter.nf_conntrack_tcp_loose=0

Also be aware of CVE-2025-40206, disclosed in 2025, which identified an issue where nftables rules referencing a synproxy object from the OUTPUT hook could trigger infinite recursive calls and crash the kernel. Make sure your kernel is patched, and never attach synproxy rules to output chains.

Per-IP Rate Limiting and Connection Throttling

Beyond SYN floods, you need to protect against application-layer abuse — brute-force login attempts, API scraping, and resource exhaustion attacks. nftables provides meters (dynamic sets with counters) for per-source-IP rate limiting, and they work remarkably well:

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

    ct state established,related accept

    # SSH brute-force protection: max 3 new connections per minute per IP
    tcp dport 22 ct state new \
        meter ssh_meter { ip saddr limit rate 3/minute burst 5 packets } \
        accept

    # If the meter limit is exceeded, add the offender to a temporary blocklist
    tcp dport 22 ct state new \
        add @rate_limited_v4 { ip saddr timeout 15m } \
        log prefix "[ssh-ratelimit] " drop

    # HTTP connection limiting: max 30 new connections per second per IP
    tcp dport { 80, 443 } ct state new \
        meter http_meter { ip saddr limit rate 30/second burst 50 packets } \
        accept

    tcp dport { 80, 443 } ct state new \
        log prefix "[http-flood] " drop
}

The meter keyword creates an in-kernel hash table that tracks the rate of matching packets per source IP. When the limit is exceeded, the packet falls through to the next rule, where we add the source IP to the rate_limited_v4 set with a 15-minute timeout. This effectively creates an automatic temporary ban without any userspace intervention — no scripts, no daemons, just pure kernel-space enforcement.

You can also use the ct count expression to limit the total number of concurrent connections from a single source:

# Limit each IP to 50 concurrent connections to web services
tcp dport { 80, 443 } ct state new \
    meter conn_count { ip saddr ct count over 50 } \
    log prefix "[conn-flood] " reject with tcp reset

Integrating Fail2Ban with nftables

While nftables' built-in rate limiting handles volumetric attacks nicely, you still need application-layer intrusion prevention. Fail2Ban monitors log files for patterns like failed SSH logins, web authentication failures, and mail server abuse, then dynamically injects firewall rules to ban offending IPs.

Since Fail2Ban 1.1.0, nftables is the default backend on distributions that ship it. However, getting the configuration right requires understanding how Fail2Ban interacts with your nftables ruleset.

First, configure Fail2Ban to use the nftables action in /etc/fail2ban/jail.local:

[DEFAULT]
banaction = nftables-multiport
banaction_allports = nftables-allports
chain = input

[sshd]
enabled  = true
port     = ssh
filter   = sshd
logpath  = /var/log/auth.log
maxretry = 3
findtime = 600
bantime  = 3600

[nginx-botsearch]
enabled  = true
port     = http,https
filter   = nginx-botsearch
logpath  = /var/log/nginx/access.log
maxretry = 5
findtime = 300
bantime  = 7200

When Fail2Ban bans an IP, it creates its own nftables table and set, inserting the banned address. But here's a common pitfall: Fail2Ban's table gets evaluated after your main firewall table if priorities aren't set correctly. Verify that Fail2Ban's chain has the correct priority:

# Check Fail2Ban's nftables structures
nft list table inet f2b-table

# You should see something like:
# table inet f2b-table {
#     set addr-set-sshd {
#         type ipv4_addr
#         elements = { 203.0.113.45 timeout 3600s }
#     }
#     chain f2b-chain {
#         type filter hook input priority -1; policy accept;
#         ip saddr @addr-set-sshd drop
#     }
# }

The priority -1 ensures Fail2Ban's chain is evaluated before your main input chain (priority 0), so banned IPs are dropped before any further processing. If you see Fail2Ban using a higher priority number, adjust it in /etc/fail2ban/action.d/nftables.conf. I've seen this misconfigured more often than you'd expect.

Geolocation-Based Filtering

For services that should only be accessible from specific countries, geolocation filtering adds a surprisingly effective defense layer. IP geolocation isn't perfectly accurate — databases depend on address owners keeping their records current — but it dramatically reduces the attack surface for region-specific services.

The approach uses external IP-to-country databases to populate nftables sets:

#!/bin/bash
# /usr/local/bin/update-geoblock.sh
# Block all traffic except from allowed countries

ALLOWED_COUNTRIES="US GB DE FR CA"
GEOIP_DIR="/var/lib/nftables/geoip"
mkdir -p "$GEOIP_DIR"

# Download country IP ranges from ipdeny.com
for country in $ALLOWED_COUNTRIES; do
    country_lower=$(echo "$country" | tr '[:upper:]' '[:lower:]')
    curl -sS "https://www.ipdeny.com/ipblocks/data/aggregated/${country_lower}-aggregated.zone" \
        > "$GEOIP_DIR/${country_lower}.zone"
done

# Build the allowed set
{
    echo "flush set inet firewall geo_allowed_v4"
    for country in $ALLOWED_COUNTRIES; do
        country_lower=$(echo "$country" | tr '[:upper:]' '[:lower:]')
        while IFS= read -r cidr; do
            [[ -z "$cidr" || "$cidr" == \#* ]] && continue
            echo "add element inet firewall geo_allowed_v4 { $cidr }"
        done < "$GEOIP_DIR/${country_lower}.zone"
    done
} | nft -f -

logger -t nft-geoblock "Geolocation sets updated for: $ALLOWED_COUNTRIES"

Then reference the set in your ruleset for sensitive services:

set geo_allowed_v4 {
    type ipv4_addr
    flags interval
    comment "Allowed country IP ranges"
}

chain ssh_rules {
    # Only allow SSH from permitted countries
    ip saddr @geo_allowed_v4 ip saddr $ADMIN_NET accept
    counter drop
}

A word of caution: never apply geolocation filtering to your primary output chain or to services that need global accessibility (like a public website). Reserve it for administrative interfaces — SSH, VPN endpoints, and management dashboards where restricting by geography makes sense.

Kernel Hardening Parameters for nftables

A firewall ruleset is only as strong as the kernel it runs on. These sysctl parameters harden the network stack to work in concert with your nftables rules:

# /etc/sysctl.d/99-nftables-hardening.conf

# ── SYN flood protection ──────────────────────────────
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_max_syn_backlog = 4096
net.ipv4.tcp_synack_retries = 2

# ── Connection tracking ───────────────────────────────
net.netfilter.nf_conntrack_tcp_loose = 0
net.netfilter.nf_conntrack_max = 1048576
net.netfilter.nf_conntrack_tcp_timeout_established = 3600

# ── Anti-spoofing ────────────────────────────────────
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1

# ── Disable source routing ───────────────────────────
net.ipv4.conf.all.accept_source_route = 0
net.ipv6.conf.all.accept_source_route = 0

# ── Disable ICMP redirects ───────────────────────────
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv6.conf.all.accept_redirects = 0

# ── Log martian packets ──────────────────────────────
net.ipv4.conf.all.log_martians = 1

# ── Disable IPv6 router advertisements (if not needed)
net.ipv6.conf.all.accept_ra = 0
net.ipv6.conf.default.accept_ra = 0

# ── TCP timestamps (required for synproxy) ───────────
net.ipv4.tcp_timestamps = 1

Pay special attention to nf_conntrack_max. On high-traffic servers, the default value (often 65536) can be exhausted during traffic spikes, causing the kernel to silently drop new connections even when your nftables rules would permit them. It's one of those issues that looks like an application problem until you think to check the conntrack table. Monitor usage with conntrack -C and set the maximum to at least 4x your typical peak concurrent connection count.

Also note CVE-2026-23139, a vulnerability in nf_conncount where garbage collection can be bypassed under sufficiently high packet rates, causing the tracking list to grow without bound. Make sure your kernel includes the patch, and monitor memory usage of the conntrack subsystem in high-throughput environments.

Automating Firewall Deployment with Ansible

A firewall that exists only in a single configuration file on one server is a liability. When you're managing dozens or hundreds of systems, you need reproducible, version-controlled firewall deployments. Ansible is a natural fit for nftables automation because it can template rulesets per host role and deploy them atomically.

Here's a practical Ansible role structure:

roles/nftables/
├── defaults/
│   └── main.yml          # Default variables
├── handlers/
│   └── main.yml          # Service reload handlers
├── tasks/
│   └── main.yml          # Installation and deployment
└── templates/
    └── nftables.conf.j2  # Jinja2 ruleset template

The defaults file defines per-role variables:

# roles/nftables/defaults/main.yml
---
nft_admin_networks:
  - 10.0.1.0/24

nft_dns_servers:
  - 1.1.1.1
  - 8.8.8.8

nft_open_tcp_ports: []
nft_ssh_port: 22
nft_enable_synproxy: false
nft_conntrack_max: 1048576

nft_blocklist_feeds:
  - name: feodo
    url: https://feodotracker.abuse.ch/downloads/ipblocklist.txt
  - name: spamhaus-drop
    url: https://www.spamhaus.org/drop/drop.txt

The Jinja2 template generates host-specific rulesets:

# roles/nftables/templates/nftables.conf.j2
#!/usr/sbin/nft -f
# Managed by Ansible — do not edit manually
# Generated: {{ ansible_date_time.iso8601 }}

flush ruleset

define ADMIN_NET   = { {{ nft_admin_networks | join(', ') }} }
define DNS_SERVERS = { {{ nft_dns_servers | join(', ') }} }

table inet firewall {
    set blocklist_v4 {
        type ipv4_addr
        flags timeout
        timeout 24h
    }

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

        ip saddr @blocklist_v4 counter drop
        iifname "lo" accept
        ct state invalid counter drop
        ct state established,related accept

        ip protocol icmp icmp type echo-request \
            limit rate 5/second burst 10 packets accept

        tcp dport {{ nft_ssh_port }} ip saddr $ADMIN_NET accept

{% for port in nft_open_tcp_ports %}
        tcp dport {{ port }} ct state new accept
{% endfor %}

        limit rate 10/minute log prefix "[nft-drop] "
    }

    chain output {
        type filter hook output priority 0; policy drop;
        oifname "lo" accept
        ct state established,related accept
        ip daddr $DNS_SERVERS udp dport 53 accept
        ip daddr $DNS_SERVERS tcp dport 53 accept
        tcp dport { 80, 443 } ct state new accept
    }
}

The deployment task validates before applying — because nobody wants to push a broken firewall config to production at 2am:

# roles/nftables/tasks/main.yml
---
- name: Install nftables
  ansible.builtin.package:
    name: nftables
    state: present

- name: Deploy nftables configuration
  ansible.builtin.template:
    src: nftables.conf.j2
    dest: /etc/nftables.conf
    owner: root
    group: root
    mode: "0600"
    validate: "nft -c -f %s"
  notify: reload nftables

- name: Enable and start nftables service
  ansible.builtin.systemd:
    name: nftables
    enabled: true
    state: started

- name: Deploy blocklist update script
  ansible.builtin.template:
    src: update-blocklists.sh.j2
    dest: /usr/local/bin/update-blocklists.sh
    mode: "0750"

- name: Schedule blocklist updates
  ansible.builtin.cron:
    name: "Update nftables blocklists"
    minute: "0"
    hour: "*/6"
    job: "/usr/local/bin/update-blocklists.sh"

And the handler:

# roles/nftables/handlers/main.yml
---
- name: reload nftables
  ansible.builtin.systemd:
    name: nftables
    state: reloaded

This setup gives you infrastructure-as-code for your firewall. Every change is version-controlled in Git, reviewed via pull request, and deployed consistently across your fleet. The validate parameter in the template task ensures that a syntactically invalid ruleset never gets written to disk, much less loaded into the kernel. It's a small safeguard that's prevented real outages in my experience.

Monitoring and Auditing Your Firewall

Deploying a firewall without monitoring it is like installing a smoke detector without batteries. You've done the hard part — now make sure you'll actually know when something triggers it.

Real-Time Rule Counters

Every rule with a counter keyword tracks packets and bytes matched. Export these metrics to your monitoring system:

#!/bin/bash
# /usr/local/bin/nft-metrics.sh
# Export nftables counter metrics in Prometheus format

echo "# HELP nft_packets_total Packets matched by nftables rules"
echo "# TYPE nft_packets_total counter"

nft -j list ruleset | jq -r '
    .nftables[] |
    select(.rule != null) |
    .rule |
    select(.expr != null) |
    .expr[] |
    select(.counter != null) |
    "nft_packets_total{chain=\"\(.chain)\",table=\"\(.table)\"} \(.counter.packets)"
'

Connection Tracking Monitoring

Monitor conntrack table utilization to prevent those silent connection drops that drive everyone crazy:

# Current conntrack entries vs maximum
echo "Conntrack usage: $(conntrack -C) / $(sysctl -n net.netfilter.nf_conntrack_max)"

# Top source IPs by connection count
conntrack -L 2>/dev/null | awk '{print $4}' | sort | uniq -c | sort -rn | head -20

# Watch for conntrack table exhaustion in kernel logs
journalctl -k --grep="nf_conntrack: table full"

Log Aggregation

Forward your nftables log prefixes to a centralized logging system. If you're already running Wazuh, configure a decoder for nftables log entries:

<!-- /var/ossec/etc/decoders/nftables_decoders.xml -->
<decoder name="nftables">
    <prematch>\[nft-</prematch>
    <regex>\[nft-(\S+)\] IN=(\S*) OUT=(\S*) SRC=(\S+) DST=(\S+)</regex>
    <order>action, srcintf, dstintf, srcip, dstip</order>
</decoder>

This creates a feedback loop: nftables drops and logs suspicious traffic, Wazuh aggregates and correlates the logs, and Fail2Ban (or your SOAR platform) takes automated response actions based on those alerts. Each tool covers what the others miss.

Patching and Vulnerability Management for nftables

The netfilter subsystem — including nftables — has been a frequent target for kernel vulnerability researchers. In 2025 and early 2026 alone, several significant CVEs were disclosed:

  • CVE-2025-21826: A bug in the pipapo (PIle PAcket POlicies) algorithm's field length calculation that affected set lookups on systems using concatenated ranges.
  • CVE-2025-40206: The synproxy objref validation issue that could crash the kernel through recursive calls from the OUTPUT hook.
  • CVE-2026-23111: An inverted activity check in nft_map_catchall_activate() that enabled local privilege escalation via user namespaces — particularly dangerous on multi-tenant systems with CONFIG_USER_NS enabled.
  • CVE-2026-23139: A garbage collection bypass in nf_conncount that allows unbounded list growth under high packet rates.

The takeaway? Staying current on kernel patches isn't optional. Your patching strategy should include: subscribing to your distribution's security mailing list, enabling automatic security updates for kernel packages, and running a live patching solution (KernelCare or kpatch) for zero-downtime kernel updates on critical systems. And always test firewall behavior after kernel updates in a staging environment before rolling to production.

Conclusion: Defense in Depth Starts at the Packet Level

A properly hardened nftables firewall is the first line of defense in any Linux security architecture. But it's exactly that — the first line, not the only one. The ruleset we built in this guide integrates with the broader security stack: Fail2Ban for application-layer threat response, Wazuh for centralized detection and correlation, kernel hardening for stack-level protection, and Ansible for consistent, auditable deployments.

The key principles to carry forward: deny-by-default on every chain (including output), use sets and maps instead of linear rules for scalability, implement connection tracking and rate limiting at the firewall level before traffic reaches your applications, automate everything so your config is reproducible and version-controlled, and monitor your firewall as actively as you monitor your applications.

nftables gives you the tools to build a genuinely sophisticated packet filtering architecture. The framework is mature, performant, and under active development. The only thing standing between your current iptables rules and a modern, zero-trust firewall is the decision to start. Pick one system, validate the ruleset, and roll it out across your fleet. Your future self — and your incident response team — will thank you.

About the Author Editorial Team

Our team of expert writers and editors.