Fail2ban has a particular way of failing. It installs cleanly, the service reports active, fail2ban-client status answers you politely, and nothing ever gets banned. No error, no warning, no clue. Just a jail sitting there with a total failure count of zero while your auth log fills up with the same three IPs trying root over and over.
Nine times out of ten the rules are fine. It’s the jail syntax. Fail2ban is unusually strict about where configuration lives and unusually quiet when a match expression is pointed at nothing, and those two things together account for most of the “it’s running but it doesn’t work” reports you’ll find.
Everything below was run on Ubuntu 24.04 with Fail2Ban v1.1.0 and Oracle Linux 9.6 with the same version. The two behave differently enough that a config copied from one to the other can look right and do nothing, which is most of the point of this article.
First, the rule about .conf and .local
Fail2ban reads /etc/fail2ban/jail.conf, then reads /etc/fail2ban/jail.local on top of it, then reads anything in /etc/fail2ban/jail.d/ on top of that. Later wins. Your changes go in jail.local or a file in jail.d/, never in jail.conf, because jail.conf is a package file and the next update overwrites it.
This is not just tidiness advice. Editing jail.conf is the reason people’s settings silently revert months later and they never connect the two events.
You only need to write the keys you’re changing. Everything else falls through to the shipped defaults, and you can confirm what actually took effect:
$ sudo fail2ban-client get sshd bantime
600
$ sudo fail2ban-client get sshd findtime
600
$ sudo fail2ban-client get sshd maxretry
5
Note that it answers in seconds even when you wrote 10m. Handy when you’re checking whether a value took, because it removes any doubt about how your unit suffix was parsed.
A jail.local that works
Here is the whole thing for Ubuntu. It’s shorter than most examples you’ll find, and that’s deliberate:
[DEFAULT]
ignoreip = 127.0.0.1/8 ::1 192.168.100.22
bantime = 10m
findtime = 10m
maxretry = 5
[sshd]
enabled = true
findtime and maxretry work as a pair: five failures inside ten minutes gets you banned for ten minutes. Change one without thinking about the other and you get surprises. A findtime of 10m with a maxretry of 3 is fairly aggressive on a box where people fat-finger passwords. A findtime of 1h with a maxretry of 5 catches slow brute force that a short window would miss entirely, because an attacker trying once every three minutes never accumulates five hits in a ten minute window.
That last case is worth sitting with. Slow-and-low scanning is specifically designed to stay under a default findtime. If you want to catch it, widen the window rather than lowering the threshold.
The syntax people get wrong
Now the part that costs people entire afternoons.
When fail2ban reads from the systemd journal instead of a log file, the jail needs a journalmatch telling it which journal entries to look at. Debian and Ubuntu ship one in /etc/fail2ban/jail.d/defaults-debian.conf:
[DEFAULT]
banaction = nftables
banaction_allports = nftables[type=allports]
[sshd]
backend = systemd
journalmatch = _SYSTEMD_UNIT=ssh.service + _COMM=sshd
enabled = true
Nearly every guide and more than one bug report will tell you the + there means AND, so both conditions have to be true. That is wrong, and it’s wrong in a way that changes how you debug this.
The fail2ban manual doesn’t define the syntax itself. It defers to journalctl(1), which is unambiguous: “the character ‘+’ may appear as a separate word between other terms on the command line. This causes all matches before and after to be combined in a disjunction (i.e. logical OR).” Matches on different fields with no + between them are ANDed. An explicit + is an OR.
So that shipped line means “entries from ssh.service, or entries from any process called sshd”. Not both.
You can watch the difference on a box where the unit name is wrong. Oracle Linux calls the service sshd.service, where Debian and Ubuntu call it ssh.service. Take the Ubuntu unit name, drop the second condition, and run it against an Oracle Linux journal:
$ sudo fail2ban-regex systemd-journal /etc/fail2ban/filter.d/sshd.conf
--journalmatch '_SYSTEMD_UNIT=ssh.service'
Lines: 0 lines, 0 ignored, 0 matched, 0 missed
Zero lines. Not zero matches out of a hundred lines. Zero lines read at all, because no journal entry has that unit. Now the correct unit for this distro:
$ sudo fail2ban-regex systemd-journal /etc/fail2ban/filter.d/sshd.conf
--journalmatch '_SYSTEMD_UNIT=sshd.service'
Lines: 55 lines, 22 ignored, 14 matched, 19 missed
And the shipped Debian expression, unit name still wrong for this box, but with the OR branch intact:
$ sudo fail2ban-regex systemd-journal /etc/fail2ban/filter.d/sshd.conf
--journalmatch '_SYSTEMD_UNIT=ssh.service + _COMM=sshd'
Lines: 55 lines, 21 ignored, 14 matched, 20 missed
Fifty-five lines and fourteen matches, on a system with no ssh.service anywhere. If the + were an AND, that would have returned nothing. It works because _COMM=sshd is a second, independent alternative, and it happens to be true on both families.
Which leads to the practical version of all this: the stock expression is accidentally portable. The moment you hand-write your own and tighten it to a single unit, because a blog told you to be specific, you lose the fallback. Then you move it to a different distro and the jail goes completely silent.
The signature to look for is Lines: 0. Zero lines means your match selected nothing and the filter never got a chance to run. Zero matched out of a non-zero line count is a different problem, which is the filter itself. Those two look similar at a glance and have nothing to do with each other.
If you’re unsure what the unit is called on the box in front of you, ask the journal rather than guessing:
journalctl -F _SYSTEMD_UNIT | grep -i ssh
There’s more on filtering the journal by field in the journalctl commands worth memorising, and the unit naming split is covered in understanding systemd service management.
Backend, or where fail2ban goes looking
The backend setting decides whether a jail reads the journal or a file on disk. Get it wrong and you get the same silent nothing, for a different reason.
On Ubuntu and Debian, defaults-debian.conf already sets backend = systemd for the sshd jail, so it works out of the box. On Oracle Linux, RHEL, Rocky and Alma there is no such file. The only thing in jail.d/ is 00-firewalld.conf, which sets the ban action and nothing else:
[DEFAULT]
banaction = firewallcmd-rich-rules
banaction_allports = firewallcmd-rich-rules
No sshd section. Nothing enabled. Fail2ban on a fresh Red Hat family install protects nothing at all until you write the jail yourself, which surprises people who assume installing it was the job.
Here’s the Oracle Linux equivalent of the config above:
[DEFAULT]
ignoreip = 127.0.0.1/8 ::1 192.168.100.22
bantime = 10m
findtime = 10m
maxretry = 5
backend = systemd
[sshd]
enabled = true
journalmatch = _SYSTEMD_UNIT=sshd.service + _COMM=sshd
Restart, and it picks up failures that were already in the journal from before the jail existed:
$ sudo fail2ban-client status sshd
Status for the jail: sshd
|- Filter
| |- Currently failed: 1
| |- Total failed: 14
| `- Journal matches: _SYSTEMD_UNIT=sshd.service + _COMM=sshd
`- Actions
|- Currently banned: 1
|- Total banned: 1
`- Banned IP list: 192.168.100.22
That Journal matches line is the one to read first when a jail isn’t banning. It shows the expression in use after all the file layering, which is not always the one you think you wrote.
When the log file isn’t there, fail2ban doesn’t sulk, it dies
The file-based alternative sets a logpath. Plenty of older guides still hand you logpath = /var/log/secure or /var/log/auth.log without mentioning that modern systemd installs may not have either, because sshd logs to the journal and nothing writes it to disk unless rsyslog is installed.
Point a jail at a file that doesn’t exist and this happens:
$ sudo fail2ban-client -t
ERROR Failed during configuration: Have not found any log file for sshd jail
ERROR ERROR: test configuration failed
And if you restart it anyway:
$ systemctl is-active fail2ban
failed
$ systemctl status fail2ban
× fail2ban.service - Fail2Ban Service
Loaded: loaded (/usr/lib/systemd/system/fail2ban.service; enabled; preset: enabled)
Active: failed (Result: exit-code) since Tue 2024-03-26 07:18:03 PKT; 11s ago
Duration: 229ms
The whole daemon. Not just that jail. One bad logpath in one jail and nothing on the box is protected, and fail2ban-client status gives you a socket error rather than an explanation. If you’ve ever had fail2ban “stop working” right after an edit, this is almost certainly what happened.
Which is the argument for fail2ban-client -t. It parses everything and tells you, without touching the running service:
$ sudo fail2ban-client -t
OK: configuration test is successful
Run it before every restart. It costs a second and it’s the difference between finding a typo now and finding it after your next reboot.
One more thing on the file side. On a box that does have rsyslog, /var/log/auth.log exists and both approaches work, which is exactly why this trips people up. The file being present on your laptop tells you nothing about the server:
$ ls -l /var/log/auth.log
-rw-r----- 1 syslog adm 43564 Mar 26 07:17 /var/log/auth.log
Check for the file on the actual machine, or skip the question and use the journal backend everywhere. On anything with systemd, the journal is the one source that’s always there.
Test the filter, not your patience
fail2ban-regex is the tool for this and it’s underused. Give it a source and a filter, and it tells you what matched:
$ sudo fail2ban-regex systemd-journal /etc/fail2ban/filter.d/sshd.conf
--journalmatch '_SYSTEMD_UNIT=ssh.service + _COMM=sshd'
Lines: 188 lines, 70 ignored, 14 matched, 104 missed
Reading that output:
- Lines: 0 means the source selected nothing. Wrong journalmatch, or wrong logpath. Your filter is irrelevant until this number moves.
- 0 matched with lines above zero means the source is right and the filter isn’t catching your log format.
- missed is normal and large. Every successful login, every sudo call, every session close counts as missed. Don’t chase it.
- ignored is usually your
ignoreipdoing its job.
If you’re stuck at 0 matched with real failures in the source, check LogLevel in /etc/ssh/sshd_config. At the default INFO some failure types aren’t logged in a form the filter recognises. Setting LogLevel VERBOSE and restarting sshd fixes a category of “fail2ban won’t catch key auth failures” complaints. There’s more on the file itself in installing and configuring SSH in Linux.
What a ban actually looks like
Worth seeing, because if you can’t find the rule you can’t tell whether the ban is real or whether the action failed halfway.
On Ubuntu the default action is nftables, and a ban creates a named set plus a chain:
$ sudo nft list table inet f2b-table
table inet f2b-table {
set addr-set-sshd {
type ipv4_addr
elements = { 192.168.100.65 }
}
chain f2b-chain {
type filter hook input priority filter - 1; policy accept;
tcp dport 22 ip saddr @addr-set-sshd reject with icmp port-unreachable
}
}
A set, not one rule per address, so a thousand banned IPs is still a single rule. That matters on a box taking real traffic.
On Oracle Linux with firewalld it’s a rich rule instead, one per banned address:
$ sudo firewall-cmd --list-rich-rules
rule family="ipv4" source address="203.0.113.77" port port="ssh" protocol="tcp" reject type="icmp-port-unreachable"
Both use reject rather than drop, so the client gets told immediately instead of hanging. If you’d rather waste a scanner’s time, drop is available, though it also means a locked-out colleague sits watching a frozen terminal with no idea why.
The log tells the same story in plainer terms:
$ sudo tail -4 /var/log/fail2ban.log
fail2ban.filter [5951]: INFO [sshd] Found 192.168.100.65 - 2024-03-26 07:17:29
fail2ban.filter [5951]: INFO [sshd] Found 192.168.100.65 - 2024-03-26 07:17:29
fail2ban.actions [5951]: NOTICE [sshd] Ban 192.168.100.65
fail2ban.filter [5951]: INFO [sshd] Found 192.168.100.65 - 2024-03-26 07:17:29
Found is a failure the filter recognised. Ban is the action firing. If you see Found lines and no Ban, your threshold hasn’t been hit or your action is broken. If you see neither, go back to the journalmatch.
You will ban yourself, so plan for it
Writing this article I enabled the jail on the Oracle box, walked away for a few seconds, and came back to this:
|- Currently banned: 1
`- Banned IP list: 192.168.100.22
That was my own workstation. The failed logins I’d generated earlier to test the filter were still sitting in the journal, well inside findtime, so the moment the jail started it processed the backlog and locked me straight out of the machine.
This is the part worth internalising: a new jail bans on history, not just on what happens next. Enabling a jail on a box that’s been scanned for a week can produce a wave of bans in the first ten seconds, and if you’ve been testing your own auth failures, one of them is you.
Two defences. First, ignoreip, set before you enable anything:
ignoreip = 127.0.0.1/8 ::1 192.168.100.22 10.0.0.0/8
Put your management network and your own address in there. It takes single addresses, CIDR ranges and hostnames, space separated.
Second, keep a second way in while you’re configuring. A console session, another SSH session already established, anything. Established connections survive the ban because the rule only affects new ones, so an open session is a lifeline. I got back in through the other lab box, which wasn’t banned:
$ sudo fail2ban-client set sshd unbanip 192.168.100.22
1
The 1 means one address was unbanned. Zero means it wasn’t banned in that jail, usually because you typed the wrong jail name.
Key-based authentication removes most of this risk, since you stop generating password failures in the first place. Worth doing regardless: setting up passwordless SSH.
Make repeat offenders pay more
A flat ten minute ban is barely an inconvenience to an automated scanner. It waits. Fail2ban has handled this since 0.11 and the feature still doesn’t show up in most guides:
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
bantime.increment = true
bantime.factor = 2
bantime.maxtime = 5w
First ban an hour, then two, then four, up to a five week ceiling. Someone who fat-fingers their password once gets a short ban they’ll probably wait out. Something hammering you daily climbs to a month. The escalation is per address and it persists across restarts in fail2ban’s database, so a reboot doesn’t reset anyone’s record.
Confirm it parsed:
$ sudo fail2ban-client -t
OK: configuration test is successful
$ sudo fail2ban-client get sshd bantime
3600
That still reports the base value. The increment is applied per address at ban time, not stored as a new jail default, so don’t read 3600 as a sign it didn’t work.
The commands worth keeping
sudo fail2ban-client -t # parse everything, change nothing
sudo fail2ban-client status # which jails are live
sudo fail2ban-client status sshd # counts, matches, banned list
sudo fail2ban-client banned # every banned IP, all jails
sudo fail2ban-client set sshd unbanip 1.2.3.4 # let someone back in
sudo fail2ban-client get sshd bantime # what a setting resolved to
fail2ban-client banned arrived in 1.0 and is the quickest way to see everything at once:
$ sudo fail2ban-client banned
[{'sshd': []}]
When it still isn’t banning
In the order that finds it fastest:
fail2ban-client status. If this errors, the daemon isn’t running, and a badlogpathis the usual reason. Check with-t.fail2ban-client status sshd. If the jail isn’t listed,enabled = truedidn’t take, or it’s in a file that isn’t being read.- Read the
Journal matchesline in that output. Confirm the unit name is the one your distro uses, not the one from the guide you copied. fail2ban-regexagainst the same source.Lines: 0means the source is wrong. Non-zero lines with 0 matched means the filter is.- Still nothing? Set
LogLevel VERBOSEin sshd_config, restart sshd, generate a real failure, and check again.
Almost every case lands on step three or four, and almost every one of those is a match expression pointing at a unit that doesn’t exist on that machine. Fail2ban won’t tell you. It’ll sit there reporting zero failures forever, perfectly healthy, watching a journal filter that selects nothing.
Check the number of lines before you touch a single regex.


