iptables is dead. RHEL 9 no longer ships it as default. Debian 12 moved to nftables. Ubuntu followed. Every major distribution now points firewall tooling at nftables — the successor that was merged into the Linux kernel back in 2014 but only recently became the unavoidable standard. If you are still writing iptables rules, you are writing for a compatibility layer that translates them to nftables behind the scenes anyway. Time to cut out the middleman.
Why nftables Replaced iptables
The iptables architecture has fundamental design problems that nftables solves:
- Single atomic ruleset replacement — nftables loads an entire ruleset in one kernel transaction. iptables applies rules one at a time, creating race conditions during updates.
- One tool for all protocols — iptables required separate binaries:
iptables,ip6tables,arptables,ebtables. nftables uses one command:nft. - Sets and maps — nftables natively supports sets (lists of IPs, ports, interfaces) and verdict maps (key→action lookups). iptables required ipset as a separate module.
- No fixed table structure — iptables forced you into predefined tables (filter, nat, mangle, raw). nftables lets you create arbitrary tables with arbitrary chains.
- Better performance — nftables uses a virtual machine in the kernel that evaluates rules more efficiently, especially with large rulesets.
Installation and Verification
# RHEL 9 / Rocky / Alma — nftables is default
sudo dnf install -y nftables
sudo systemctl enable --now nftables
# Debian 12 / Ubuntu 24.04
sudo apt install -y nftables
sudo systemctl enable --now nftables
# Verify kernel support
nft list ruleset
# Should output current rules (may be empty)
# Check version
nft --version
# nftables v1.0.9 (Dire Straits)
# IMPORTANT: Stop and disable iptables services if they exist
sudo systemctl stop iptables 2>/dev/null
sudo systemctl disable iptables 2>/dev/null
sudo systemctl stop firewalld 2>/dev/null # firewalld uses nftables backend now
nftables Concepts: The Mental Model
Before writing rules, understand the hierarchy:
# Table → Chain → Rule
#
# TABLE: A namespace (any name, any family)
# Families: ip, ip6, inet (dual-stack), arp, bridge, netdev
#
# CHAIN: Attached to a netfilter hook (or not — base vs regular chains)
# Base chain hooks: input, output, forward, prerouting, postrouting
# Regular chains: called from other chains (like functions)
#
# RULE: A match + action (accept, drop, reject, counter, log, jump, etc.)
# The inet family handles both IPv4 and IPv6 in ONE table
# This is the biggest ergonomic win over iptables
Your First nftables Ruleset
# Create the ruleset file
cat > /etc/nftables.conf << 'EOF'
#!/usr/sbin/nft -f
# Flush existing rules
flush ruleset
# Main firewall table — inet = IPv4 + IPv6
table inet filter {
# Input chain — traffic destined for this host
chain input {
type filter hook input priority filter; policy drop;
# Connection tracking — accept established/related, drop invalid
ct state established,related accept
ct state invalid drop
# Loopback
iif "lo" accept
# ICMP and ICMPv6 — essential for network functionality
ip protocol icmp accept
ip6 nexthdr icmpv6 accept
# SSH
tcp dport 22 accept
# HTTP/HTTPS
tcp dport { 80, 443 } accept
# Log dropped packets (rate-limited to avoid log flooding)
limit rate 5/minute burst 10 packets log prefix "nft-drop: " level warn
}
# Forward chain — traffic passing through this host
chain forward {
type filter hook forward priority filter; policy drop;
ct state established,related accept
ct state invalid drop
}
# Output chain — traffic originating from this host
chain output {
type filter hook output priority filter; policy accept;
}
}
EOF
# Load the ruleset atomically
sudo nft -f /etc/nftables.conf
# Verify
sudo nft list ruleset
Sets: The Feature iptables Engineers Dream About
# Named sets replace ipset entirely
# Define an IP set inline
table inet filter {
set trusted_ips {
type ipv4_addr
flags interval
elements = {
10.0.0.0/8,
172.16.0.0/12,
192.168.0.0/16
}
}
set blocked_countries {
type ipv4_addr
flags interval
# Auto-expire entries after 24 hours
flags timeout
timeout 24h
}
set rate_limited {
type ipv4_addr
# Dynamically populated by rules
flags dynamic,timeout
timeout 5m
size 65536
}
chain input {
type filter hook input priority filter; policy drop;
ct state established,related accept
# Allow trusted IPs to everything
ip saddr @trusted_ips accept
# Block banned IPs
ip saddr @blocked_countries drop
# Rate limiting: add source IP to set if exceeding threshold
tcp dport 22 ct state new limit rate over 5/minute add @rate_limited { ip saddr } drop
ip saddr @rate_limited drop
tcp dport 22 accept
tcp dport { 80, 443 } accept
}
}
# Manage sets at runtime (no ruleset reload needed)
sudo nft add element inet filter trusted_ips { 203.0.113.50 }
sudo nft delete element inet filter trusted_ips { 203.0.113.50 }
sudo nft list set inet filter trusted_ips
Verdict Maps: Replace Dozens of Rules with One
Verdict maps are the most powerful nftables feature that iptables cannot replicate. They map a key to an action in a single rule — replacing chains of if/then rules.
table inet filter {
# Map ports to verdict (accept/drop/jump)
map port_policy {
type inet_service : verdict
elements = {
22 : accept,
80 : accept,
443 : accept,
8080 : jump rate_limit_chain,
3306 : drop,
5432 : drop
}
}
chain input {
type filter hook input priority filter; policy drop;
ct state established,related accept
iif "lo" accept
# One rule replaces six separate rules
tcp dport vmap @port_policy
# You can also inline maps
tcp dport vmap {
25 : jump smtp_chain,
53 : accept,
853 : accept
}
}
chain rate_limit_chain {
limit rate 100/second burst 50 packets accept
drop
}
chain smtp_chain {
ip saddr 10.0.0.0/8 accept
drop
}
}
Translating iptables Rules to nftables
# The iptables-translate tool converts rules automatically
iptables-translate -A INPUT -p tcp --dport 22 -j ACCEPT
# nft add rule ip filter input tcp dport 22 accept
iptables-translate -A INPUT -s 10.0.0.0/8 -p tcp --dport 3306 -j ACCEPT
# nft add rule ip filter input ip saddr 10.0.0.0/8 tcp dport 3306 accept
# Translate an entire saved ruleset
iptables-save > /tmp/old-rules.txt
iptables-restore-translate -f /tmp/old-rules.txt > /tmp/nft-rules.nft
# WARNING: The translation is mechanical and does not use nftables
# features like sets, maps, or inet family. Always review and refactor.
# Common translations:
# iptables → nftables
# -A INPUT → add rule inet filter input
# -p tcp --dport 80 → tcp dport 80
# -p tcp --dport 80:90 → tcp dport 80-90
# -p tcp -m multiport --dports 80,443 → tcp dport { 80, 443 }
# -m state --state ESTABLISHED → ct state established
# -j ACCEPT → accept
# -j DROP → drop
# -j REJECT → reject
# -j LOG --log-prefix "X" → log prefix "X"
# -j SNAT --to-source 1.2.3.4 → snat to 1.2.3.4
# -j MASQUERADE → masquerade
NAT with nftables
table ip nat {
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
# Port forwarding: external:8080 → internal:80
tcp dport 8080 dnat to 10.0.1.50:80
# Forward based on destination port map
tcp dport vmap {
8080 : dnat to 10.0.1.50:80,
8443 : dnat to 10.0.1.50:443,
2222 : dnat to 10.0.1.100:22
}
}
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
# Masquerade outbound traffic from LAN
ip saddr 10.0.1.0/24 oifname "eth0" masquerade
# Or SNAT with a fixed IP (better performance than masquerade)
ip saddr 10.0.1.0/24 oifname "eth0" snat to 203.0.113.10
}
}
Production Hardening Ruleset
#!/usr/sbin/nft -f
# /etc/nftables.conf — Production server hardening
flush ruleset
define LAN_NET = 10.0.0.0/8
define MONITOR_IPS = { 10.0.5.10, 10.0.5.11 }
table inet firewall {
set ssh_allowed {
type ipv4_addr
flags interval
elements = { 10.0.0.0/8, 172.16.0.0/12 }
}
set bruteforce {
type ipv4_addr
flags dynamic,timeout
timeout 15m
size 65536
}
chain input {
type filter hook input priority filter; policy drop;
# Fast-track established connections
ct state vmap {
established : accept,
related : accept,
invalid : drop
}
# Loopback
iif "lo" accept
# ICMP rate-limited
ip protocol icmp limit rate 10/second accept
ip6 nexthdr icmpv6 limit rate 10/second accept
# SSH with brute-force protection
tcp dport 22 ct state new {
ip saddr @bruteforce drop
ip saddr @ssh_allowed limit rate 3/minute burst 5 packets accept
ip saddr != @ssh_allowed limit rate over 3/minute add @bruteforce { ip saddr } drop
}
# Web traffic
tcp dport { 80, 443 } ct state new accept
# Monitoring (Prometheus, node_exporter)
ip saddr $MONITOR_IPS tcp dport { 9090, 9100 } accept
# Log and drop everything else
limit rate 3/minute log prefix "nft-blocked: "
}
chain forward {
type filter hook forward priority filter; policy drop;
}
chain output {
type filter hook output priority filter; policy accept;
}
}
# Include additional rules from a directory
include "/etc/nftables.d/*.nft"
Testing and Debugging
# Dry-run: check syntax without applying
sudo nft -c -f /etc/nftables.conf
# No output = valid syntax
# List all rules with counters
sudo nft list ruleset -a
# List a specific chain
sudo nft list chain inet firewall input
# Add a counter to see which rules are matching
sudo nft add rule inet firewall input counter
# Trace packets through the ruleset
sudo nft add chain inet firewall trace_chain { type filter hook prerouting priority -300 \; }
sudo nft add rule inet firewall trace_chain meta nftrace set 1
sudo nft monitor trace
# Watch rule counters in real-time
watch -n 1 'nft list chain inet firewall input'
# Export ruleset as JSON (for programmatic management)
sudo nft -j list ruleset | jq '.'
nftables is not just a syntax update over iptables — it is a fundamentally better architecture for packet filtering. Sets eliminate the need for ipset. Verdict maps replace entire chain hierarchies with single rules. Atomic ruleset loading eliminates race conditions. And the inet family finally unifies IPv4 and IPv6 firewall management. The migration is not optional anymore — it is already happening whether you manage it or not.
