fail2ban that never banned anyone: '/bin/sh: 1: nft: not found'

·4 mins

fail2ban said the address was banned:

$ sudo fail2ban-client status nginx-limit-req
...
   `- Banned IP list:	203.0.113.10

From that address, right after:

$ curl -s -o /dev/null -w '%{http_code}\n' https://example.com/
302

The reason was in /var/log/fail2ban.log all along:

2026-03-14 10:21:05,205 fail2ban.utils [1293]: ERROR   7f5e8fbb0d30 -- exec: nft add table inet f2b-table
2026-03-14 10:21:05,206 fail2ban.utils [1293]: ERROR   7f5e8fbb0d30 -- stderr: '/bin/sh: 1: nft: not found'

On Debian, fail2ban bans through the nft command. A host that runs firewalld can easily lack it. fail2ban then counts failures, lists bans, and blocks nothing, including the sshd jail.

The setup

  • Debian 13, fail2ban 1.1.0-8, firewalld 2.3.1 with FirewallBackend=nftables
  • nginx as a reverse proxy in front of a few web apps, SSH forwarded from the router
  • fail2ban set up by hand, with the stock sshd jail

Debian’s /etc/fail2ban/jail.d/defaults-debian.conf picks the ban action:

[DEFAULT]
banaction = nftables
banaction_allports = nftables[type=allports]

How it showed up

I was adding jails for the web logins, so I tested them from a VPS outside every whitelisted network. Testing from home proves nothing when home is in ignoreip. Twenty-five quick POSTs to a login endpoint:

for i in $(seq 25); do
  curl -s -o /dev/null -w '%{http_code} ' -d client_id=probe https://example.com/login
done

The first 11 got 400 from the app, the rest 429 from nginx’s rate limit, and the nginx-limit-req jail banned the VPS as it should. Then the next request got a 302, as if nothing had happened.

sudo nft list table inet f2b-table answered sudo: nft: command not found. The log held ten lines like the ones above. Each failed ban left two ERROR lines in a log nobody reads, while fail2ban-client status still listed the bans.

The fix

Install the nft command:

sudo apt install nftables
sudo systemctl restart fail2ban

Leave nftables.service disabled, as the package installs it. Debian’s /etc/nftables.conf starts with flush ruleset, so enabling the service would wipe firewalld’s rules at the next boot. fail2ban doesn’t need the service, only the binary. It keeps its bans in a table of its own, inet f2b-table, next to firewalld’s inet firewalld.

On restart, fail2ban restores the bans it has on record, so the ones that had silently failed now take effect:

fail2ban.actions [1649343]: NOTICE  [nginx-limit-req] Restore Ban 203.0.113.10

Give it a few seconds before you check. Right after the restart, nft list table inet f2b-table still said No such file or directory. A moment later, it showed the set and the rule:

table inet f2b-table {
	set addr-set-nginx-limit-req {
		type ipv4_addr
		elements = { 203.0.113.10 }
	}
	chain f2b-chain {
		type filter hook input priority filter - 1; policy accept;
		tcp dport { 80, 443 } ip saddr @addr-set-nginx-limit-req reject with icmp port-unreachable
	}
}

This time, curl on the VPS couldn’t connect at all (exit code 7). If you manage the host with Ansible or similar, add nftables to the package list alongside fail2ban so a rebuilt host doesn’t lose it again.

Why the binary was missing

firewalld talks to the kernel through the python3-nftables bindings, not the nft command, so it doesn’t pull in the nftables package. fail2ban only recommends it as an alternative:

$ apt-cache show fail2ban | grep ^Recommends
Recommends: nftables | iptables, whois, python3-pyinotify, python3-setuptools

iptables was already installed (firewalld recommends it), so apt considered the recommendation met. The ban action still called nft.

The same thing can happen with any ban action whose command isn’t on the host: the action fails, and the address stays on the ban list anyway.

Takeaway

Banned IP list shows what fail2ban decided, not what the firewall does. The only proof that a ban works is a blocked connection from the banned address, and grep ERROR /var/log/fail2ban.log tells you why one isn’t.

Get one of your own hosts to ban a machine outside its whitelist, then try to connect from that machine.