Set up fail2ban and block brute force attacks automatically
How to configure fail2ban cleanly through jail.local, get the sshd jail running on all four current LTS distributions, verify and lift bans, and which mistakes will otherwise slow you down.
Every server with a public IPv4 address starts collecting login attempts on port 22 within minutes of its first deployment. That is not a targeted attack, it is background noise. fail2ban reads the log lines those failed attempts produce and has the firewall block the source IP for a defined period. The tool is packaged in every distribution covered here and is set up in a few minutes. The pitfalls are elsewhere: on Debian 12 fail2ban likes to start with a configuration error, on some systems it appears to ban without a single packet actually being dropped, and one careless line in jail.local locks you out of your own server.
This article covers exactly those spots, for Debian 13, Debian 12, Ubuntu 24.04 LTS and Ubuntu 22.04 LTS.
Why jail.local and never jail.conf
The file /etc/fail2ban/jail.conf belongs to the package. Every update can overwrite it, and then dpkg asks you about it in the best case, and on an unattended update run it does not ask at all. Your changes therefore live in /etc/fail2ban/jail.local. That file does not exist after installation, you create it yourself, and it should contain only the values you actually change.
What matters is the order in which fail2ban reads. The manual page jail.conf(5) names it explicitly:
jail.confjail.d/*.confin alphabetical orderjail.localjail.d/*.localin alphabetical order
Files read later win. In concrete terms: your jail.local also overrides the defaults from /etc/fail2ban/jail.d/defaults-debian.conf that the distribution package ships. Many guides claim the opposite and therefore recommend a separate file in jail.d/. That is not necessary. jail.d/ only makes sense if you want to roll out jails individually through configuration management.
What your distribution already decides for you
The biggest difference between the four systems is not in fail2ban itself, it sits in this one file that the package drops into /etc/fail2ban/jail.d/defaults-debian.conf. Look at it first:
cat /etc/fail2ban/jail.d/defaults-debian.conf
| System | fail2ban | Contents of defaults-debian.conf |
|---|---|---|
| Debian 13 | 1.1.0 | banaction = nftables, banaction_allports = nftables[type=allports], and for [sshd] additionally backend = systemd, journalmatch = _SYSTEMD_UNIT=ssh.service + _COMM=sshd and enabled = true |
| Debian 12 | 1.0.2 | only [sshd] and enabled = true |
| Ubuntu 24.04 | 1.0.2 | banaction = nftables, banaction_allports = nftables[type=allports], backend = systemd, plus [sshd] with enabled = true |
| Ubuntu 22.04 | 0.11.2 | only [sshd] and enabled = true |
Almost everything else follows from this. Debian 13 and Ubuntu 24.04 read the systemd journal out of the box and ban through nftables. Debian 12 and Ubuntu 22.04 fall back to the defaults from jail.conf, which means banaction = iptables-multiport and backend = auto. And auto explicitly does not mean "pick whatever fits". The comment in jail.conf says that auto tries pyinotify and then polling. Both are file-based methods. The value auto never selects the journal. Without the file /var/log/auth.log, the sshd jail has nothing to read there.
Installation and the first check
apt update
apt install -y fail2ban
On Debian 12 and Ubuntu 22.04 one more package is needed if you want to evaluate the journal instead of a log file:
apt install -y python3-systemd
On Debian 13, python3-systemd is a hard dependency of the package and already present. On Debian 12 it is merely recommended, which is the most common source of trouble on that system. Then check version and state:
apt-cache policy fail2ban
fail2ban-client --version
systemctl status fail2ban --no-pager
If this already shows active (running), you are most of the way there. If it says failed, jump straight to the section on error messages.
Building your own jail.local
Create /etc/fail2ban/jail.local. This version works on all four systems as long as python3-systemd is installed:
[DEFAULT]
ignoreip = 127.0.0.1/8 ::1 203.0.113.7
bantime = 1h
findtime = 10m
maxretry = 5
bantime.increment = true
bantime.factor = 2
bantime.maxtime = 7d
banaction = nftables
banaction_allports = nftables[type=allports]
[sshd]
enabled = true
backend = systemd
port = ssh
maxretry = 4
bantime = 2h
What the values mean in plain terms: findtime is the sliding time window, maxretry the number of failed attempts inside it, bantime the duration of the ban. Four failed attempts in ten minutes lead to a two hour ban here. Time values understand suffixes such as m, h, d and w, a bare number means seconds. A bantime of -1 bans permanently.
The interesting part is bantime.increment. With it, fail2ban doubles the ban time every time the same IP comes back, capped by bantime.maxtime. A single typo from a real user costs two hours, a persistent bot ends up at a week after a few rounds. This feature has existed since fail2ban 0.11, so it is available on Ubuntu 22.04 as well. For it to work across restarts, the database has to keep the history, which is controlled by dbpurgeage in /etc/fail2ban/fail2ban.conf, one day by default. If you set bantime.maxtime = 7d, raise dbpurgeage in a fail2ban.local as well.
Two pitfalls in this block. First, port = ssh: that resolves through /etc/services to port 22. If your SSH service runs on 2222, the line has to read port = 2222, otherwise you get a firewall rule for a port nobody is knocking on. Second, ignoreip: put your fixed office IP in there, but never a whole provider network. And do not rely on it alone. The value ignoreself is set to true by default anyway and protects the server's own IPs.
Differences between the systems
On Debian 12 you have two equally valid options. Either you stay with backend = systemd and install python3-systemd, or you install rsyslog, leave backend out and work with /var/log/auth.log. On Debian 12 you should also set banaction deliberately. The package recommends nftables or iptables, and apt usually installs only nftables of the two. The default from jail.conf, however, is iptables-multiport. On a slim installation fail2ban then reaches for a binary that is not there at all.
On Ubuntu 22.04, rsyslog is part of the server default, /var/log/auth.log exists, and the jail runs without any help. If you do not want to change anything there, simply leave backend and banaction out of your jail.local. fail2ban 0.11.2 does know the nftables action, but the route through iptables-multiport is the better trodden one on this system.
On Debian 13 and Ubuntu 24.04, your jail.local matches the package defaults. That does no harm, it only makes the configuration explicit and therefore readable.
Applying the configuration and verifying that it really bites
Reload the configuration without losing existing bans:
fail2ban-client reload
On Debian 12 and Ubuntu 24.04, fail2ban 1.0.2 emits the warning 'allowipv6' not defined in 'Definition' on start and on reload. It is harmless, the service keeps working normally.
A systemctl restart fail2ban is rarely needed and resets the running bans. Then the status queries:
fail2ban-client ping
fail2ban-client status
fail2ban-client status sshd
The last output is the informative one. It shows Currently failed, Total failed, the number of bans and the list of banned addresses. This is exactly where most guides stop, and exactly where the problem starts: a jail that starts cleanly and reports "0 banned" can be completely blind. A jail without hits looks identical to a jail that is reading the wrong log source.
Three checks make that difference visible. First: does the filter see the lines at all?
fail2ban-regex systemd-journal /etc/fail2ban/filter.d/sshd.conf
With a file-based backend, use this instead:
fail2ban-regex /var/log/auth.log /etc/fail2ban/filter.d/sshd.conf
At the end you get a line such as Lines: 4211 lines, 0 ignored, 137 matched, 4074 missed. If it says 0 matched even though the log visibly contains failed attempts, the filter or the journal selection does not fit.
Second: which journal filter is the jail actually using?
fail2ban-client get sshd journalmatch
The answer differs from system to system, and that difference matters. Debian 13 reports _SYSTEMD_UNIT=ssh.service + _COMM=sshd, Ubuntu 24.04 reports _SYSTEMD_UNIT=sshd.service + _COMM=sshd. So the unit is called sshd.service there and ssh.service on Debian. On Debian 12 and Ubuntu 22.04 you get back No journal match filter set in the shipped state, because the systemd backend is not preset there and the jail reads a log file. That is not an error, it is confirmation that no journal filter exists at all. Only backend = systemd in your jail.local makes one take effect on those systems.
The individual conditions are separated by + and combined as OR. So _SYSTEMD_UNIT=ssh.service + _COMM=sshd matches everything from the unit ssh.service or any process named sshd. For the cross-check in the journal, use exactly the unit name from the output above:
journalctl -u ssh --no-pager -n 50
journalctl _COMM=sshd --no-pager -n 50
On Ubuntu 24.04 the first line accordingly reads journalctl -u sshd --no-pager -n 50. Use the wrong name here and you get empty output, then go looking at the wrong end.
If lines with Failed password for invalid user show up here but fail2ban counts nothing, the journal selection is to blame. This becomes relevant if you run OpenSSH from backports or switch to socket activation. Since OpenSSH 9.8 the child process is called sshd-session and no longer sshd, and with socket activation the unit is no longer called ssh.service. In that case, add the following to your jail.local (on Ubuntu 24.04 with _SYSTEMD_UNIT=sshd.service):
[sshd]
journalmatch = _SYSTEMD_UNIT=ssh.service + _COMM=sshd + _COMM=sshd-session
Third, the hard test. Ban an address from the documentation range by hand and check whether it arrives in the firewall:
fail2ban-client set sshd banip 203.0.113.10
The command answers with 1, meaning one banned address. Which tool you use to prove that ban depends on the effective banaction, and that is preset differently per distribution:
| System | banaction out of the box | Checking the active ban |
|---|---|---|
| Debian 13 | nftables | nft list table inet f2b-table |
| Ubuntu 24.04 | nftables | nft list table inet f2b-table |
| Debian 12 | iptables-multiport | iptables -n -L f2b-sshd |
| Ubuntu 22.04 | iptables-multiport | iptables -n -L f2b-sshd |
The older Debian 11 behaves like Debian 12 here. Where the difference comes from is visible in /etc/fail2ban/jail.d/defaults-debian.conf: only Debian 13 and Ubuntu 24.04 ship a banaction = nftables there, the rest stay with the default iptables-multiport from jail.conf. If you do not want to keep that straight per server, put banaction = nftables into your own jail.local, as in the example above, and check with nft everywhere afterwards.
With the nftables action there is a table f2b-table in the inet family, containing a chain f2b-chain with priority filter - 1 and a set called addr-set-sshd. That is where 203.0.113.10 has to appear as an element. There is no chain f2b-sshd on these systems, and iptables -n -L f2b-sshd answers with No chain/target/match by that name. The other way round, nft list table inet f2b-table reports Error: No such file or directory on Debian 12 and Ubuntu 22.04 as long as iptables-multiport is doing the work there. Neither message proves a broken configuration, only the wrong command for the configured action.
With the iptables action, 203.0.113.10 has to show up in the chain f2b-sshd accordingly. Only once the IP appears in the firewall is the path from the log entry to the dropped packet complete. Do not forget to clean up afterwards.
Lifting bans again
Release a single address from one specific jail:
fail2ban-client set sshd unbanip 203.0.113.10
One address from all jails at once:
fail2ban-client unban 203.0.113.10
And in an emergency, everything back to zero:
fail2ban-client unban --all
The last command is the one you need when you have locked yourself out and are sitting at an emergency console on the server. It lifts every ban without restarting the service. If you cannot reach the server at all any more, the only way in is the console in the customer panel. On KVM root servers and dedicated servers from KernelHost you get a VNC session there that works independently of SSH. That is also the reason why you should not set bantime = -1 on a server without a second way in.
A ban you lift comes straight back if the failed attempts are still being counted inside the time window. That is why unban clears the associated failed attempts as well. Anyone who should stay permanently free belongs in ignoreip, followed by fail2ban-client reload.
Interaction with UFW and nftables
This is where the damage that is hardest to notice happens, because fail2ban cheerfully keeps reporting bans while the packets go through.
UFW is a frontend for iptables. On start or reload it rewrites the filter table through iptables-restore. In the process the chains created by fail2ban disappear without replacement, and fail2ban does not recreate them on its own. So after every ufw enable, ufw disable or ufw reload: systemctl restart fail2ban. Until then, fail2ban-client status sshd lists a long row of banned IPs, not a single one of which is actually blocked.
The cleaner approach is to let fail2ban ban through UFW directly. The action lives in /etc/fail2ban/action.d/ufw.conf and is included in all four distributions. In jail.local:
[DEFAULT]
banaction = ufw
banaction_allports = ufw
Then the bans end up as rules in UFW itself and survive its reloads. To check:
ufw status numbered
Banned addresses appear there as DENY IN near the top of the list. What matters is that the ban rules sit before your allow rule for port 22, otherwise the allow rule wins first. That is why the bundled action inserts them at the top with insert.
If you do not use UFW and maintain your own rule set in /etc/nftables.conf instead, you have a related problem. A flush ruleset at the top of that file, which the default template suggests, also deletes f2b-table on reload. Either you drop the global flush and clear only your own table, or you tie a fail2ban restart to the nftables service. The advantage of the table fail2ban creates itself is that with priority filter - 1 it takes effect before the normal filter table and therefore also before rules coming from iptables-nft.
What you should not mix is banaction = nftables and your own iptables rule set, in the expectation that iptables -L will show the bans. Both paths work in parallel, but each tool only shows its own rule set. Always check with the tool that matches the configured banaction.
Error messages verbatim and what is behind them
Depending on the system, you will find the logs in two places:
journalctl -u fail2ban --no-pager -n 100
tail -n 100 /var/log/fail2ban.log
Failed during configuration: Have not found any log file for sshd jail
The signature message of Debian 12. The jail runs with backend = auto, looks for /var/log/auth.log, and the file does not exist because no rsyslog is installed and everything goes into the journal. The service does not even start, systemctl status fail2ban shows Failed with result 'exit-code'. Two solutions: either apt install -y rsyslog and one restart, or set the line backend = systemd under [sshd] in jail.local. The second option is the more modern one, but it requires the next package.
Backend 'systemd' failed to initialize due to No module named 'systemd'
You have set backend = systemd, but the Python binding to the journal is missing. On Debian 12, python3-systemd is only recommended and is not pulled in on installations without recommended packages. The fix:
apt install -y python3-systemd
systemctl restart fail2ban
Failed to access socket path: /var/run/fail2ban/fail2ban.sock. Is fail2ban running?
The client cannot reach the server. In the vast majority of cases the service is simply not running because it failed on a configuration error. Read systemctl status fail2ban first, then the journal. Rarely, an orphaned socket file is left behind after a hard abort, and then the server start reports Server already running. In that case, remove the socket file and start again.
NOK: ('sshd',)
The answer to fail2ban-client status sshd when that jail does not exist at runtime. Either enabled = true is missing, or you mistyped the name, or a syntax error further up in jail.local swallowed the section. Which jails are really running is shown by fail2ban-client status. What fail2ban assembled from all the files is shown by the configuration dump:
fail2ban-client -d
Error banning and iptables not found
The jail counts correctly, but the ban fails on the action. Typical on slim Debian 12 installations where only nftables is present while the default says iptables-multiport. Either add apt install -y iptables, or switch to banaction = nftables in jail.local. The second option is the one Debian 13 and Ubuntu 24.04 take by themselves anyway.
When nothing helps any more: the orderly rollback
Because all your changes live in jail.local, the way back is short. Move the file aside, restart the service, and you are back at the package defaults:
mv /etc/fail2ban/jail.local /root/jail.local.bak
systemctl restart fail2ban
fail2ban-client status
If it runs like that, the fault is in your configuration, and you add the blocks back one at a time. If it does not run like that either, the environment is to blame, so a missing log source or a missing firewall tool. That is exactly why jail.conf stays untouched: this ten second rollback is only possible as long as the package file is in its shipped state.
Finally, some perspective. fail2ban reduces log noise and stops slow, repeated login attempts. A serious attack from a large botnet uses every address only once and walks past any rate limit. The more effective step against brute force on SSH remains switching off password authentication and using keys instead. And against volumetric attacks, only filtering inside the network helps anyway, in our case through 3.2 Tbps of Arbor real-time filtering directly in the datacenter in Frankfurt am Main and up to 17 Tbps of global filtering capacity in the Professional plans. fail2ban is the layer below that, on the host, and there it does its job reliably as soon as three things are right: the correct log source, the correct firewall action and a jail.local that you can remove again in one go if in doubt.
Frequently asked questions
Do I really have to leave jail.conf untouched?
Why does fail2ban not start on Debian 12 after installation?
What exactly does backend = auto mean?
How do I lift a ban again?
Why does fail2ban ban, yet the attacker still gets through?
How do I know that fail2ban really works?
Does fail2ban replace DDoS protection?
2026 KernelHost GmbH. All rights reserved. This guide is protected by copyright. Republishing it on other websites, in whole, in part or in edited form, is not permitted without our written consent. Quoting with a source credit and a link is expressly welcome.

