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.
Confirm which firewall backend is actually active
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.
Match banaction to that backend
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.
Read fail2ban.log for failed actions, not just ban lines
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.
Check whether something flushes the rules after Fail2ban adds them
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.
Make sure the rule sits on the path the traffic takes
- Wrong port. A
multiportaction blocks only the ports listed in the jail'sportsetting. If SSH moved to a custom port and the jail still saysssh, the ban covers port 22 and nothing else. Theallportsvariants 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
inetfamily, which covers both. Older iptables setups need the IPv6 side handled as well, so confirm that yours does.
Prove enforcement with a test ban
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.10nft 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.
The short version to keep at the desk
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.