TL;DR — Quick Summary
nftables replaces iptables on Linux with cleaner syntax and atomic rule loading. Full migration guide with sets, NAT, rate limiting, and web server firewall.
nftables is the Linux kernel’s modern packet classification framework and the official replacement for the legacy iptables, ip6tables, arptables, and ebtables toolchain. Available since kernel 3.13 and now the default backend on Debian 10+, Ubuntu 20.10+, RHEL 8+, and Fedora 18+, nftables delivers a unified command interface, native set-based matching, and atomic ruleset loading that eliminates the security gaps inherent in per-rule iptables updates. This guide walks through the complete migration path — from installing nftables and understanding its object hierarchy to building a production-ready web server firewall with sets, rate limiting, NAT, and persistent systemd integration.
Prerequisites
Before you begin, make sure you have:
- A Linux server running kernel 3.13 or later (Ubuntu 20.04+, Debian 10+, or RHEL 8+ recommended)
- Terminal access with
sudoprivileges - SSH access to the server if configuring remotely — keep a second session open as a safety net
- Familiarity with TCP/UDP ports and basic IP networking
- Existing iptables rules exported if you plan to migrate them (
sudo iptables-save > ~/iptables-backup.txt)
Why nftables Replaces iptables
The legacy Netfilter toolchain evolved organically over two decades, resulting in four separate utilities (iptables, ip6tables, arptables, ebtables) each with its own syntax, kernel interface, and extension mechanism. nftables was designed from the ground up to resolve these structural problems.
Unified Framework
One nft command replaces all four legacy tools. A single inet family table handles both IPv4 and IPv6 simultaneously, eliminating the need to duplicate every rule for both address families.
Better Syntax
nftables uses a structured grammar that reads more naturally than iptables flags:
# iptables
iptables -A INPUT -p tcp --dport 443 -m state --state NEW -j ACCEPT
# nftables equivalent
nft add rule inet filter input tcp dport 443 ct state new accept
Atomic Rule Loading
With iptables, adding and removing rules happens one at a time — a race condition exists between each individual operation. nftables loads an entire ruleset as a single atomic transaction using nft -f, ensuring the firewall is never in a partial state.
Native Sets and Maps
iptables requires the separate ipset extension to match against large lists of IPs or ports efficiently. nftables includes sets and maps natively, supporting timeouts, concatenated keys, and verdict maps with no additional packages.
The nft Command Basics
nftables organizes everything into a three-level hierarchy: tables contain chains, chains contain rules. Every table belongs to an address family.
Address Families
| Family | Traffic handled |
|---|---|
ip | IPv4 only |
ip6 | IPv6 only |
inet | Both IPv4 and IPv6 (recommended for servers) |
arp | ARP protocol |
bridge | Bridge/switch level |
netdev | Ingress on a specific interface |
Managing Tables
sudo nft add table inet filter # create a table
sudo nft list tables # list all tables
sudo nft delete table inet filter # delete table and contents
sudo nft flush table inet filter # remove all rules, keep structure
Managing Chains
Base chains are attached to a Netfilter hook. They require a type, hook, and priority:
sudo nft add chain inet filter input { type filter hook input priority 0 \; policy drop \; }
sudo nft add chain inet filter forward { type filter hook forward priority 0 \; policy drop \; }
sudo nft add chain inet filter output { type filter hook output priority 0 \; policy accept \; }
Managing Rules
sudo nft add rule inet filter input tcp dport 22 accept # append
sudo nft insert rule inet filter input tcp dport 22 accept # prepend
sudo nft list chain inet filter input # view with handles
sudo nft delete rule inet filter input handle 5 # delete by handle
Building a Basic Firewall
The following sequence builds a complete baseline firewall step by step.
Step 1 — Create the Table and Chains
sudo nft add table inet filter
sudo nft add chain inet filter input { type filter hook input priority 0 \; policy drop \; }
sudo nft add chain inet filter forward { type filter hook forward priority 0 \; policy drop \; }
sudo nft add chain inet filter output { type filter hook output priority 0 \; policy accept \; }
Step 2 — Accept Established Connections and Loopback
These rules must come first so existing sessions are not broken when you drop all new traffic:
sudo nft add rule inet filter input ct state established,related accept
sudo nft add rule inet filter input ct state invalid drop
sudo nft add rule inet filter input iif lo accept
Step 3 — Allow ICMP
sudo nft add rule inet filter input ip protocol icmp limit rate 10/second burst 20 packets accept
sudo nft add rule inet filter input ip6 nexthdr icmpv6 limit rate 10/second burst 20 packets accept
Step 4 — Allow Service Ports
sudo nft add rule inet filter input tcp dport 22 accept
sudo nft add rule inet filter input tcp dport { 80, 443 } accept
Step 5 — Final Reject
Send an ICMP error to unmatched traffic rather than silently dropping it:
sudo nft add rule inet filter input reject with icmpx type port-unreachable
Verify the Ruleset
sudo nft list ruleset
Expected output:
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
ct state established,related accept
ct state invalid drop
iif "lo" accept
ip protocol icmp limit rate 10/second burst 20 packets accept
ip6 nexthdr ipv6-icmp limit rate 10/second burst 20 packets accept
tcp dport 22 accept
tcp dport { 80, 443 } accept
reject with icmpx type port-unreachable
}
chain forward {
type filter hook forward priority 0; policy drop;
}
chain output {
type filter hook output priority 0; policy accept;
}
}
Sets and Maps for Efficient Rule Management
Sets eliminate the need to write one rule per port or IP address. They can be updated dynamically without touching the rules that reference them.
Anonymous Sets (Inline)
sudo nft add rule inet filter input tcp dport { 22, 80, 443, 8443 } accept
Named Sets
# Create the set
sudo nft add set inet filter tcp_accepted { type inet_service \; }
# Populate it
sudo nft add element inet filter tcp_accepted { 22, 80, 443 }
# Reference it in a rule (no rule change needed when updating the set)
sudo nft add rule inet filter input tcp dport @tcp_accepted accept
# Add or remove ports without touching the rule
sudo nft add element inet filter tcp_accepted { 8080 }
sudo nft delete element inet filter tcp_accepted { 8080 }
IP Blocklist with Auto-Expiry
sudo nft add set inet filter blocklist { type ipv4_addr \; timeout 24h \; }
sudo nft add element inet filter blocklist { 203.0.113.10, 198.51.100.5 }
sudo nft add rule inet filter input ip saddr @blocklist drop
Entries expire automatically after 24 hours unless refreshed.
Concatenated Sets (Multi-Field Matching)
sudo nft add set inet filter db_access { type ipv4_addr . inet_service \; }
sudo nft add element inet filter db_access { 10.0.1.20 . 3306, 10.0.1.21 . 5432 }
sudo nft add rule inet filter input ip saddr . tcp dport @db_access accept
Verdict Maps
sudo nft add map inet filter port_verdict { type inet_service : verdict \; }
sudo nft add element inet filter port_verdict { 22 : accept, 80 : accept, 443 : accept, 23 : drop }
sudo nft add rule inet filter input tcp dport vmap @port_verdict
Rate Limiting and Connection Tracking
Global SSH Rate Limit
Limit new SSH connections across all source addresses:
sudo nft add rule inet filter input tcp dport 22 ct state new limit rate 5/minute burst 10 packets accept
sudo nft add rule inet filter input tcp dport 22 ct state new drop
Per-Source-IP Rate Limiting with Meters
Meters enforce limits independently for each source address — far more effective against distributed brute force:
sudo nft add rule inet filter input tcp dport 22 ct state new \
meter ssh_limit { ip saddr limit rate 3/minute burst 5 packets } accept
Connection Tracking States
| State | Meaning |
|---|---|
new | First packet of a new connection |
established | Return traffic for an accepted connection |
related | Associated with an existing connection (e.g., FTP data) |
invalid | Cannot be identified — always drop |
untracked | Explicitly bypassed conntrack |
NAT and Port Forwarding
Create the NAT Table
sudo nft add table inet nat
sudo nft add chain inet nat prerouting { type nat hook prerouting priority -100 \; }
sudo nft add chain inet nat postrouting { type nat hook postrouting priority 100 \; }
Masquerade (Source NAT for Routers/VPNs)
sudo nft add rule inet nat postrouting oif "eth0" masquerade
For a specific subnet:
sudo nft add rule inet nat postrouting ip saddr 10.0.0.0/24 oif "eth0" masquerade
Port Forwarding (Destination NAT)
Forward external port 8080 to an internal web server:
sudo nft add rule inet nat prerouting iif "eth0" tcp dport 8080 dnat to 10.0.0.50:80
sudo nft add rule inet filter forward ip daddr 10.0.0.50 tcp dport 80 ct state new accept
sudo nft add rule inet filter forward ct state established,related accept
Enable IP forwarding:
echo "net.ipv4.ip_forward = 1" | sudo tee /etc/sysctl.d/99-nftables.conf
sudo sysctl -p /etc/sysctl.d/99-nftables.conf
Logging and Monitoring
Log Before Drop
sudo nft add rule inet filter input ct state invalid log prefix "nft_invalid: " level warn drop
Rate-Limited Logging
Prevent log flooding during attacks:
sudo nft add rule inet filter input tcp dport 22 ct state new \
limit rate 1/minute log prefix "nft_ssh_new: " level info accept
View Kernel Logs
sudo journalctl -k --grep="nft_" -f
sudo dmesg | grep "nft_"
Real-Time Rule Monitoring
sudo nft monitor
Add Counters to Rules
sudo nft add rule inet filter input tcp dport 443 counter accept
sudo nft list chain inet filter input # shows packets and bytes per rule
Saving and Restoring Rulesets
Export the Current Ruleset
sudo nft list ruleset | sudo tee /etc/nftables.conf
File-Based Configuration (Recommended for Production)
Managing rulesets in a file enables version control and atomic application. A complete /etc/nftables.conf:
#!/usr/sbin/nft -f
flush ruleset
define WAN = eth0
define SSH_RATE = 5/minute
table inet filter {
set tcp_accepted {
type inet_service
elements = { 22, 80, 443 }
}
set blocklist {
type ipv4_addr
timeout 24h
}
chain input {
type filter hook input priority 0; policy drop;
ct state established,related accept
ct state invalid drop
iif lo accept
ip saddr @blocklist drop
ip protocol icmp limit rate 10/second burst 20 packets accept
ip6 nexthdr ipv6-icmp limit rate 10/second burst 20 packets accept
tcp dport 22 ct state new meter ssh_limit { ip saddr limit rate $SSH_RATE burst 10 packets } accept
tcp dport @tcp_accepted accept
reject with icmpx type port-unreachable
}
chain forward {
type filter hook forward priority 0; policy drop;
ct state established,related accept
}
chain output {
type filter hook output priority 0; policy accept;
}
}
Load and validate:
sudo nft -c -f /etc/nftables.conf # dry-run syntax check
sudo nft -f /etc/nftables.conf # apply atomically
Migrating from iptables
Translate Individual Rules
iptables-translate -A INPUT -p tcp --dport 443 -j ACCEPT
# Output: nft add rule ip filter INPUT tcp dport 443 counter accept
Translate a Complete Ruleset
sudo iptables-save > /tmp/ipt-backup.txt
iptables-restore-translate -f /tmp/ipt-backup.txt > /tmp/nft-migrated.nft
cat /tmp/nft-migrated.nft # review carefully before applying
sudo nft -f /tmp/nft-migrated.nft
Check the Compatibility Layer
Modern Ubuntu uses iptables-nft by default. Verify:
iptables --version
# Legacy: iptables v1.8.7 (legacy)
# nftables backend: iptables v1.8.7 (nf_tables)
If you see nf_tables, existing iptables commands already translate to nftables internally.
Migration Best Practices
- Back up first —
sudo iptables-save > ~/iptables-backup.txt - Translate and review — never apply auto-translated rules blindly
- Test on non-production — apply translated rules on a staging server first
- Keep a console session — cloud console or KVM access as a fallback if SSH is lost
- Flush iptables after confirming —
sudo iptables -F && sudo iptables -X - Disable legacy service —
sudo systemctl disable iptablesorip6tablesif present
systemd Integration
Enable nftables to load /etc/nftables.conf at boot:
sudo systemctl enable nftables
sudo systemctl start nftables
sudo systemctl status nftables
Reload the ruleset without rebooting:
sudo systemctl reload nftables
The reload triggers nft -f /etc/nftables.conf which includes flush ruleset — the swap is atomic.
Comparison: nftables vs iptables vs UFW vs firewalld
| Feature | nftables | iptables | UFW | firewalld |
|---|---|---|---|---|
| Single tool for all traffic | Yes | No (4 tools) | Yes (frontend) | Yes (frontend) |
| IPv4 + IPv6 in one table | Yes (inet) | No | Yes | Yes |
| Native sets/maps | Yes | No (needs ipset) | No | No |
| Atomic rule replacement | Yes (nft -f) | No | No | Partial |
| GUI / simplified syntax | No | No | Yes | Yes |
| Dynamic rule updates | Yes | Limited | Limited | Yes (zones) |
| Scripting flexibility | High | Medium | Low | Medium |
| Performance at scale | Excellent | Good | Good | Good |
| Distribution default | Debian 10+, RHEL 8+ | Legacy distros | Ubuntu default | RHEL/Fedora default |
Choose nftables when you need full control, high performance, or complex multi-table configurations. Choose UFW when you want a simpler interface on Ubuntu with fewer rules to manage. Choose firewalld when working with RHEL/CentOS and zone-based policies.
Real-World Scenario: Web Server and Reverse Proxy Firewall
You have a production server running Nginx as a reverse proxy in front of two backend application servers at 10.0.0.10 and 10.0.0.11. The server has one public interface (eth0) and one internal interface (eth1). Requirements: SSH access from your management CIDR only, public HTTP/HTTPS, backend traffic only from the internal network, and SSH brute-force protection.
#!/usr/sbin/nft -f
flush ruleset
define WAN = eth0
define LAN = eth1
define MGMT_CIDR = 10.10.10.0/24
define BACKEND_1 = 10.0.0.10
define BACKEND_2 = 10.0.0.11
table inet filter {
set mgmt_hosts {
type ipv4_addr
flags interval
elements = { $MGMT_CIDR }
}
set backend_servers {
type ipv4_addr
elements = { $BACKEND_1, $BACKEND_2 }
}
chain input {
type filter hook input priority 0; policy drop;
ct state established,related accept
ct state invalid drop
iif lo accept
# ICMP with rate limiting
ip protocol icmp limit rate 10/second burst 20 packets accept
ip6 nexthdr ipv6-icmp limit rate 10/second burst 20 packets accept
# SSH restricted to management CIDR with per-IP rate limiting
ip saddr @mgmt_hosts tcp dport 22 ct state new \
meter ssh_mgmt { ip saddr limit rate 5/minute burst 10 packets } accept
# Public web traffic
tcp dport { 80, 443 } accept
# Backend health check access from LAN
iif $LAN ip saddr @backend_servers tcp dport { 8080, 8081 } accept
reject with icmpx type port-unreachable
}
chain forward {
type filter hook forward priority 0; policy drop;
ct state established,related accept
# Allow Nginx to forward to backends
iif $WAN oif $LAN ip daddr @backend_servers tcp dport { 8080, 8081 } ct state new accept
}
chain output {
type filter hook output priority 0; policy accept;
}
}
table inet nat {
chain prerouting {
type nat hook prerouting priority -100;
# Distribute incoming traffic across backend servers
iif $WAN tcp dport 80 dnat to numgen inc mod 2 map { 0 : $BACKEND_1:8080, 1 : $BACKEND_2:8080 }
iif $WAN tcp dport 443 dnat to numgen inc mod 2 map { 0 : $BACKEND_1:8081, 1 : $BACKEND_2:8081 }
}
chain postrouting {
type nat hook postrouting priority 100;
oif $LAN masquerade
}
}
Save to /etc/nftables.conf, validate with sudo nft -c -f /etc/nftables.conf, and apply.
Gotchas and Edge Cases
- Rule order matters — nftables evaluates rules top to bottom within a chain. Place
ct state established,related acceptbefore any drop rules. - No default tables or chains — unlike iptables, nftables starts empty. If you forget to create a chain, traffic is not filtered at all.
- flush ruleset is destructive — always include it at the top of config files to ensure a clean slate, but never run it interactively on a live server without a backup plan.
- Variables require
define— nftables usesdefine VAR = valuein config files; you cannot use shell variables insidenftcommands directly. - inet family does not cover bridge — if you filter bridged traffic (e.g., in a VM hypervisor), add a separate
bridgefamily table. - iptables and nftables can conflict — if both are running, rules from both frameworks apply independently. Flush iptables rules after confirming nftables works correctly.
- Meters are not sets — meters created inline in rules are not listed in
nft list sets; usenft list metersto inspect them.
Troubleshooting
Rules not taking effect after reboot: Verify the service is enabled with sudo systemctl is-enabled nftables. Check that /etc/nftables.conf contains flush ruleset at the top and valid nft syntax.
SSH locked out after applying rules: Access via cloud console, then run sudo nft flush ruleset to clear all rules. Rebuild the configuration with SSH allowed before setting a drop policy.
Rules applied but traffic still blocked: Run sudo nft list ruleset to confirm rules are loaded. Add temporary counter rules (counter accept) to diagnose which chain is dropping the traffic.
iptables and nftables conflict: Run sudo iptables -L -n to check for legacy rules. Flush them with sudo iptables -F and sudo iptables -X, then stop any legacy iptables service.
Kernel module missing: Run lsmod | grep nf_tables. If absent, load with sudo modprobe nf_tables nft_chain_nat nft_ct.
Syntax error in config file: Use sudo nft -c -f /etc/nftables.conf for a dry-run that reports the exact line and error without applying any changes.
Summary
nftables is the production-ready replacement for iptables on any Linux system running kernel 3.13 or later. Its unified nft command, native sets and maps, atomic rule loading, and cleaner syntax address every major structural weakness of the legacy toolchain.
Key takeaways:
- Use the
inetaddress family to manage both IPv4 and IPv6 from a single table - Always open with
ct state established,related acceptandiif lo acceptto preserve existing sessions - Use named sets to manage lists of ports and IPs without rewriting rules
- Protect authentication services with per-IP meters rather than global rate limits
- Store the ruleset in
/etc/nftables.confand load atomically withnft -f - Enable the nftables systemd service so the ruleset persists across reboots
- Use
iptables-restore-translateto convert existing rulesets, then review carefully before applying