Bali, Indonesia
Insights

Why Fail2ban Bans Stop Working After Moving from iptables to nftables

October 20, 2026
0:00
You upgrade a server to a newer Linux release. Fail2ban starts fine and fail2ban.log keeps printing Ban lines, but the same IPs keep hammering SSH or the mail login. The usual cause is not Fail2ban detection. Its ban action writes rules somewhere the kernel isn't using, the action fails quietly, or something else wipes the rules after they're added. The background, briefly: iptables and nftables are two front ends to the Linux packet filter. Many newer releases build the firewall on nftables, and the iptables command you type may now be a compatibility wrapper (often called iptables-nft) that converts your rules into nftables underneath. Fail2ban does not know any of this. It runs the shell commands defined in its banaction, and if those commands target the wrong place, you get a ban that exists only in Fail2ban's own memory. The checklist below runs roughly in order of how often each item turns out to be the cause.
Start here, because every later step depends on it. Run iptables -V. The output usually shows (nf_tables) or (legacy) after the version string. On Debian and Ubuntu, update-alternatives --display iptables shows which binary the iptables command points to. Then check what's managing the firewall. Is nftables.service enabled? Is firewalld or ufw running? Is the box a plain server with no manager at all? Customers often say "we use iptables" after an upgrade when the system quietly moved to nftables under them. Asking what the box is running, rather than what it used to run, saves a lot of back and forth on a support ticket. One trap: legacy iptables rules and nftables rules live in separate places. nft list ruleset won't show rules created by iptables-legacy, and iptables-legacy -L won't show native nftables tables. If you look in only one place, you can conclude the bans are missing when they're sitting in the other one, or the reverse.
Fail2ban picks its ban action from banaction and banaction_allports, which are set in the [DEFAULT] section of jail.conf and normally overridden in jail.local or a file under jail.d/. Find the effective value instead of trusting the file you think is in charge. Some distro packages ship their own defaults file in jail.d/, and anything there can override what you set elsewhere. For a host where nftables is the real backend and no firewall manager is involved, the nftables actions (nftables, nftables-multiport, nftables-allports) are the natural fit. If firewalld owns the firewall, the firewallcmd-* actions keep Fail2ban working through firewalld rather than around it. If ufw is in charge, there's a ufw action. The iptables actions can still work on an iptables-nft system, since the wrapper translates them. Mixing tools, though, makes later debugging harder, and it breaks completely if the iptables binary isn't installed. List /etc/fail2ban/action.d/ to see which actions your installed version actually has. After changing banaction, restart Fail2ban fully rather than just reloading one jail. That way old rules are torn down and the new action sets up its own table or chain from scratch.
A Ban line means Fail2ban decided to ban. It doesn't mean the firewall command succeeded. When the action command fails, Fail2ban logs an error near the ban line, usually mentioning the action name and a non-zero return code. Exit code 127 typically means the command wasn't found. That's what you'd see on a minimal nftables-only install where iptables was never installed but the jail still uses an iptables action. Search the log for ERROR around a ban timestamp. If the action failed, the rest of this checklist is moot until the banaction is fixed.
This one is easy to miss. The bans work right after a Fail2ban restart, then stop some time later. Common culprits are a reload of nftables.service, a firewalld reload, or a config management run that reapplies the firewall. On many systems, /etc/nftables.conf starts with flush ruleset. That line removes every table, including the one Fail2ban created, each time the service loads the file. Fail2ban isn't told about it, so fail2ban-client status keeps listing banned IPs that no longer have a matching rule. Fixes depend on your setup. One option is to restart Fail2ban after any firewall reload, for example with a systemd ordering or a drop-in. Another is to replace the global flush with flushing only the tables your config file owns, so Fail2ban's table survives. Whatever you choose, test it: reload the firewall the way your automation does, then confirm Fail2ban's rules are still present.
A rule can exist and still never see the packets. Three situations come up often:
  • Wrong port. A multiport action blocks only the ports listed in the jail's port setting. If SSH moved to a custom port and the jail still says ssh, the ban covers port 22 and nothing else. The allports variants avoid this at the cost of blocking the IP from every service.
  • Containers. Traffic to Docker-published ports is normally handled on the forward path, not input, so a ban hooked to input won't stop it. Banning for containerized services needs the action pointed at the right hook or chain for your setup.
  • IPv6. If the attacker comes in over IPv6 and your action only covers IPv4, the ban does nothing for that address. The nftables actions generally use the inet family, which covers both. Older iptables setups need the IPv6 side handled as well, so confirm that yours does.
In nftables, a drop verdict in any base chain is final, while an accept in one chain doesn't skip the others on the same hook. So a ban in its own table normally isn't overridden by an accept rule elsewhere. If bans are being bypassed, look at port, hook, and address family before blaming rule order.
Don't stop when the config looks right. Ban a known address and check that the rule really lands in the ruleset you identified in the first step. The commands below are read-only, except the banip and unbanip pair. It uses 192.0.2.10, an address reserved for documentation, so it won't lock out a real user. Replace sshd with your jail name.
BASH
# Which backend does the iptables command use?
iptables -V

# Which actions is the jail actually running?
fail2ban-client get sshd actions

# Ban a documentation-range test IP
fail2ban-client set sshd banip 192.0.2.10

# Look for it in the live nftables ruleset
nft list ruleset | grep -n '192.0.2.10'

# If nothing shows up, check whether it landed in legacy iptables instead
iptables-legacy -S 2>/dev/null | grep -n '192.0.2.10'

# Remove the test ban
fail2ban-client set sshd unbanip 192.0.2.10
If the address shows up in nft list ruleset inside a table Fail2ban owns (often named f2b-table, depending on version), the action is writing to the live firewall. If it only shows up under iptables-legacy, Fail2ban is writing to the legacy tables, which are likely no longer your real firewall. If it shows up nowhere, go back to the log and look for the failed action. For a full end-to-end check, ban an IP you control from a second machine and try to connect. Use a spare address, not your own management IP, and keep an out-of-band console handy in case you lock yourself out.
When Fail2ban logs bans but attackers still get through after an upgrade, work through it in this order: confirm the backend, set banaction to match the tool that owns the firewall, read the log for failed actions, check that reloads don't flush the rules, then test with a documentation IP. Most cases are solved by the first two steps. The flush problem is the one that comes back weeks later, so it's worth testing explicitly once the fix is in.