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 withCONFIG_USER_NSenabled. - CVE-2026-23139: A garbage collection bypass in
nf_conncountthat 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.