nftables + fail2ban — Wall and doorman¶
The problem¶
You open port 22 to the internet and within an hour your logs hold thousands of login attempts. A well-configured firewall doesn't help: port 22 has to be open for you to get in. And fail2ban alone isn't enough either, because it only reacts to what already arrived.
The two pieces are complementary, and most guides treat them separately: nftables decides what may arrive (static policy) and fail2ban decides who stops being allowed (dynamic reaction to what does arrive). This page is about how they actually fit together, which is where nearly every configuration goes wrong.
Relationship to other pages
Network Firewall compares UFW, iptables and nftables and introduces Suricata and Zeek. SSH Hardening covers configuring sshd itself. This page covers the persistent ruleset and its fail2ban integration, which neither of those covers in depth.
📋 Table of Contents¶
- An nftables ruleset you can read
- Sets: lists looked up in O(1)
- Applying it without locking yourself out
- fail2ban: the part that's usually wrong
- The recidive jail
- Custom filters
- Day-to-day operation
- Troubleshooting
- Best practices
- References
An nftables ruleset you can read¶
The practical difference from iptables isn't the syntax: it's that the ruleset is a file, loaded atomically and read top to bottom like a program.
#!/usr/sbin/nft -f
# /etc/nftables.conf
flush ruleset
table inet filter {
# TCP ports open to everyone
set public_tcp {
type inet_service
elements = { 80, 443 }
}
# Trusted administration networks
set admin_nets {
type ipv4_addr
flags interval
elements = { 192.168.1.0/24, 10.8.0.0/24 }
}
chain input {
type filter hook input priority filter; policy drop;
# 1. Already-established traffic: the most frequent case, goes first
ct state established,related accept
ct state invalid drop
# 2. Loopback
iif lo accept
# 3. ICMP: don't block it wholesale, it breaks PMTU and diagnostics
ip protocol icmp icmp type { echo-request, destination-unreachable, time-exceeded } accept
ip6 nexthdr icmpv6 accept
# 4. Public services
tcp dport @public_tcp accept
# 5. Administration, only from trusted networks
ip saddr @admin_nets tcp dport 22 accept
# 6. Whatever reaches here gets logged before it drops
limit rate 5/minute burst 10 packets log prefix "nft-drop: " level info
}
chain forward {
type filter hook forward priority filter; policy drop;
}
chain output {
type filter hook output priority filter; policy accept;
}
}
Three decisions that make the difference:
policy droponinputandforward. What isn't allowed doesn't pass. The alternative (policy acceptplus block rules) requires you to correctly anticipate everything you want to forbid, forever.ct state established,related acceptfirst. It is the rule that evaluates the vast majority of packets; putting it last means walking the whole ruleset for every packet of an already-accepted connection.logwithlimit rate. Without the limit, a port scan fills/var/logand turns a minor incident into a full disk.
Don't block all ICMP
This is the classic "ping = danger" reflex. Blocking destination-unreachable breaks path MTU discovery, and the symptom is TCP connections hanging mid-transfer on large packets — a failure that takes days to diagnose. See MTU/MSS.
Sets: lists looked up in O(1)¶
A set is a hash table in the kernel. Whether it holds 5 elements or 50,000, lookup cost is the same. That is exactly what you want for block lists.
# Blocking with automatic expiry
set blackhole {
type ipv4_addr
flags dynamic, timeout
timeout 1h
}
ip saddr @blackhole drop
# Add an IP with its own expiry
sudo nft add element inet filter blackhole '{ 203.0.113.7 timeout 24h }'
# See what's in there and how long it has left
sudo nft list set inet filter blackhole
flags timeout makes the kernel expire entries by itself: no cron to clean up, no file growing unbounded. It is the mechanism fail2ban works on when using the native backend.
Applying it without locking yourself out¶
The mistake everyone makes once: loading a policy drop ruleset that doesn't permit your SSH, over SSH.
# 1. Validate the syntax without applying anything
sudo nft -c -f /etc/nftables.conf
# 2. Safety net: restore the previous ruleset in 2 minutes
sudo nft list ruleset > /root/nft-backup.conf
sudo systemd-run --on-active=120 --timer-property=AccuracySec=1s \
nft -f /root/nft-backup.conf
# 3. Now apply
sudo nft -f /etc/nftables.conf
# 4. Still connected and everything works? Cancel the restore
sudo systemctl list-timers --all | grep run-
sudo systemctl stop run-rXXXX.timer
Persistence across reboots:
sudo systemctl enable --now nftables
fail2ban: the part that's usually wrong¶
The configuration circulating everywhere has been outdated for years on two points that make the jail not work without telling you.
# /etc/fail2ban/jail.local
[DEFAULT]
# 1. On Debian 12+, Ubuntu 24.04+ and RHEL 9+ there is no /var/log/auth.log:
# rsyslog is no longer installed by default and everything goes to the journal.
backend = systemd
# 2. If your firewall is nftables, the banaction must be too.
banaction = nftables-multiport
banaction_allports = nftables-allports
bantime = 1h
findtime = 10m
maxretry = 5
# Never ban yourself
ignoreip = 127.0.0.1/8 ::1 192.168.1.0/24
[sshd]
enabled = true
mode = aggressive
backend = systemd: withlogpath = /var/log/auth.logon a modern distro, fail2ban starts, the jail finds no file and sits idle.systemctl status fail2banstays green. It's a silent failure, which is the worst kind.banaction = nftables-multiport: with the iptables default on an nftables system, bans land in theiptables-nftcompatibility layer and coexist badly with your ruleset. Working sometimes is worse than never working.
Check the jail is genuinely alive:
sudo fail2ban-client status # active jails
sudo fail2ban-client status sshd # detected failures and bans
If Currently failed: 0 and Total failed: 0 after hours exposed to the internet, the jail is reading nothing. It isn't that nobody is attacking you.
The recidive jail¶
A serious attacker doesn't make 5 attempts and leave: they make 4, wait, come back. They never trigger maxretry. recidive watches fail2ban's own log and punishes repeat offenders across jails.
[recidive]
enabled = true
logpath = /var/log/fail2ban.log
banaction = %(banaction_allports)s
bantime = 1w
findtime = 1d
maxretry = 3
Three bans in 24 hours → a week out, and on all ports, not just the one that failed. It is the jail with the best effort-to-effect ratio.
recidive reads a file, not the journal
Even when everything else uses backend = systemd, recidive needs /var/log/fail2ban.log. Verify it exists and that logtarget = /var/log/fail2ban.log is set in fail2ban.local; if your install logs to the journal, this jail will detect nothing.
Custom filters¶
For any service that writes failed attempts to a log: one regex and one jail.
# /etc/fail2ban/filter.d/myapp.conf
[Definition]
failregex = ^.*Failed login attempt from <HOST>.*$
ignoreregex =
# In jail.local
[myapp]
enabled = true
port = http,https
filter = myapp
logpath = /var/log/myapp/access.log
maxretry = 5
Test the regex against the real log before enabling it:
sudo fail2ban-regex /var/log/myapp/access.log /etc/fail2ban/filter.d/myapp.conf
The output tells you how many lines matched. If it's 0, the filter is decorative. <HOST> is the macro capturing the IP: without it the filter cannot ban anyone.
Day-to-day operation¶
# Who is banned right now
sudo fail2ban-client status sshd
# Unban (the user who mistyped their key three times)
sudo fail2ban-client set sshd unbanip 203.0.113.7
# Ban manually
sudo fail2ban-client set sshd banip 203.0.113.7
# See the bans from the firewall side
sudo nft list set inet f2b-table addr-set-sshd
Ruleset counters, to see which rule is doing the work:
sudo nft -a list ruleset # with handles, for deleting specific rules
sudo nft list ruleset | grep -A3 counter
Add counter to the rules you want to measure; without that keyword nftables counts nothing (unlike iptables, which always counts).
Troubleshooting¶
| Symptom | Cause | Check |
|---|---|---|
| fail2ban active but never bans | Wrong backend for the distro |
fail2ban-client status sshd → Total failed: 0 |
| Bans, but the IP still gets in | iptables banaction on nftables |
nft list ruleset \| grep f2b |
| You locked yourself out | SSH rule missing or after the drop |
Physical console / KVM; nft flush ruleset |
| Custom filter detects nothing | Regex without <HOST>, or not matching |
fail2ban-regex against the real log |
| Rules lost after reboot | nftables service disabled |
systemctl is-enabled nftables |
| Connections hang on large files | ICMP blocked, PMTU broken | ping -M do -s 1472 <target> |
Best practices¶
policy dropby default, with theestablishedrule first.- Validate with
nft -cand always start with the timed safety net. The cost is one command; the cost of skipping it is a trip to the datacenter. backend = systemdandbanaction = nftables-*on any modern install. Copy-pasting from old guides is the number one cause of decorative fail2ban.ignoreipwith your admin network, before touching anything else.- Enable
recidive. That's where the return is. fail2ban-regexbefore trusting a custom filter.- Feed your monitoring with the bans. A spike in bans is a signal, not noise. See security monitoring and Wazuh.
- The firewall replaces nothing else. SSH with keys and no passwords (SSH Hardening) eliminates the entire attack class; fail2ban only reduces the noise.