Short answer: install fail2ban with sudo apt install fail2ban nftables python3-systemd, leave jail.conf alone and put your settings into /etc/fail2ban/jail.local: your own IP address in ignoreip first, then the sshd jail. On Debian 12 that jail also needs backend = systemd, or fail2ban refuses to start. fail2ban-client status sshd shows what it banned, and fail2ban-client set sshd unbanip IP lifts a ban.
sudo apt update
sudo apt install -y fail2ban nftables python3-systemd
sudo nano /etc/fail2ban/jail.localThe complete jail.local is below; the minimum is the [DEFAULT] line with your IP and the [sshd] section. Then test and apply:
sudo fail2ban-client -t
sudo systemctl restart fail2ban
sudo fail2ban-client status sshd-t reads the whole configuration and prints OK: configuration test is successful, or the line that is wrong, without touching the running service. The configuration files here pass that test with fail2ban 1.0.2 on Debian 12, the filters were run through fail2ban-regex, and the defaults were checked against the packages of Debian 13 and Ubuntu 24.04 and 26.04.
jail.conf, jail.local and jail.d: which file wins
fail2ban reads jail.conf, then jail.d/*.conf in alphabetical order, then jail.local, then jail.d/*.local; a value read later wins. jail.conf belongs to the package and is replaced on upgrades. A .local file only needs the keys you change, so do not copy the whole jail.conf into it, as many guides suggest: that freezes every default at today’s value. Two syntax rules catch people out:
- A comment is a line that starts with
#. After a value, only;preceded by a space starts a comment;port = 2222 # sshpasses the whole text on as the port. - A section may appear once per file. A second
[sshd]stops the configuration withsection 'sshd' already exists.
sudo fail2ban-client -d prints the configuration as fail2ban resolved it, with every jail’s backend, log paths and ban commands, which settles any doubt about which file won.
What Debian and Ubuntu ship
The stock settings differ by release, and the differences explain most “fail2ban does not start” and “fail2ban never bans” reports:
| Release | Version | Stock setup | What you have to do |
|---|---|---|---|
| Debian 12 | 1.0.2 | sshd jail reads /var/log/auth.log, bans with iptables | That file does not exist, because Debian 12 logs to the journal only, so fail2ban stops with Have not found any log file for sshd jail. Set backend = systemd and an nftables ban action |
| Debian 13 | 1.1.0 | sshd jail reads the journal, bans with nftables | Install the nftables package if nft is missing |
| Ubuntu 24.04 | 1.0.2 | Every jail reads the journal (backend = systemd in [DEFAULT]), bans with nftables | Jails for log files, such as nginx and recidive, need backend = auto |
| Ubuntu 26.04 | 1.1.0 | sshd jail reads the journal, bans with nftables | Nothing for SSH |
The nftables actions run the nft command from the nftables package. Debian’s cloud images of 12 and 13 ship only iptables, and fail2ban merely recommends “nftables or iptables”, so apt does not add it: hence nftables in the install line. Installing it enables no rules of its own; its service stays off. The journal backend needs python3-systemd, which Debian 12’s package also only recommends, so it is named too; newer packages depend on it anyway. On Ubuntu 24.04, the fail2ban package first shipped with the release crashed on Python 3.12 with No module named 'asynchat'; version 1.0.2-3ubuntu0.1 fixed it, which is why apt update comes first.
A jail.local that works on Debian and Ubuntu
This passes fail2ban-client -t on Debian 12 and does the right thing on the newer releases, where it repeats what the package already sets:
[DEFAULT]
# Never ban these. Put your own fixed IP or range here FIRST.
ignoreip = 127.0.0.1/8 ::1 203.0.113.10 198.51.100.0/24
bantime = 1h
findtime = 10m
maxretry = 5
# Repeat offenders: 1h, 2h, 4h ... capped at one week
bantime.increment = true
bantime.maxtime = 1w
# Ban through nftables (the Debian 12 default is iptables)
banaction = nftables
banaction_allports = nftables[type=allports]
[sshd]
enabled = true
# Read sshd from the journal: Debian 12 has no /var/log/auth.log
backend = systemdignoreiptakes addresses and CIDR ranges, IPv4 and IPv6, separated by spaces. The server’s own addresses are ignored anyway (ignoreselfis on by default). A jail that sets its ownignoreipreplaces this list, so repeat your address there.maxretry,findtime,bantime: 5 failures from one address within 10 minutes mean a ban for one hour. The stock values are 5, 10 minutes and 10 minutes. Times takem,h,dandw;bantime = -1bans for good, which on a busy server only grows the ban list forever.bantime.incrementdoubles the ban each time the same address is banned again, using the history in/var/lib/fail2ban/fail2ban.sqlite3, so it survives restarts.bantime.maxtimecaps it. The log then showsIncrease Banlines.banaction = nftablescreates its own tableinet f2b-tablewith a chain that runs before the rules UFW manages, and an address set per jail and IP version. Banned clients are rejected, so most seeConnection refused; some, Windows among them, wait for a timeout instead. Because the bans live in that table,sudo ufw statusnever lists them;sudo nft list table inet f2b-tabledoes. If you would rather see bans in UFW,banaction = ufwadds each one as a UFW rule, which blocks the address on every port.backend = systemdin[sshd]only. Do not put it into[DEFAULT]: jails for nginx and recidive read log files, and with the journal backend fail2ban ignores theirlogpath, searches the journal, where those logs are not, and never matches, without a warning.
If SSH listens on another port, add port = 2222 to [sshd], or fail2ban bans attackers on 22 while they knock on 2222; the SSH port change guide has the details.
Normal or aggressive mode for SSH
The sshd filter has modes. normal counts failed logins and invalid users; aggressive also counts connections that are dropped before or during authentication, such as scanners that never try a login and clients offering no accepted key or no common cipher. Add mode = aggressive to [sshd]. On a two-hour sample of SSH log from an internet-facing server, normal mode matched 63 of 193 lines and aggressive mode 107.
Check that it bans
sudo fail2ban-client status
sudo fail2ban-client status sshd
sudo fail2ban-client get sshd ignoreip
sudo tail -f /var/log/fail2ban.logStatus for the jail: sshd
|- Filter
| |- Currently failed: 2
| |- Total failed: 57
| `- Journal matches: _SYSTEMD_UNIT=sshd.service + _COMM=sshd
`- Actions
|- Currently banned: 2
|- Total banned: 4
`- Banned IP list: 192.0.2.77 192.0.2.140Total failed must grow over time on any server reachable from the internet. A jail that reads log files shows File list: instead of Journal matches:. The log records each step:
2026-11-04 09:12:44,071 fail2ban.filter [812]: INFO [sshd] Found 192.0.2.77 - 2026-11-04 09:12:44
2026-11-04 09:12:52,388 fail2ban.actions [812]: NOTICE [sshd] Ban 192.0.2.77Unban an IP address
# Which jails hold this address? Prints [[]] if none
sudo fail2ban-client banned 203.0.113.10
# Unban from one jail: prints 1 if it was banned, 0 if not
sudo fail2ban-client set sshd unbanip 203.0.113.10
# Unban from every jail at once
sudo fail2ban-client unban 203.0.113.10An unban also clears the address from the database, so it starts from zero. If you locked yourself out, run the unban from the console, add the address to ignoreip, and load the change with sudo fail2ban-client reload. The opposite, sudo fail2ban-client set sshd banip 192.0.2.77, bans an address by hand.
Test a filter with fail2ban-regex
fail2ban-regex runs a filter over a log line or file and reports what it would count, without banning anything. It reads the journal too, but over a large journal that is slow; exporting a recent window is quicker:
sudo journalctl -u ssh --since "-2h" -o short > /tmp/ssh.log
fail2ban-regex /tmp/ssh.log sshd
fail2ban-regex /tmp/ssh.log 'sshd[mode=aggressive]'
# One line against a filter, e.g. before enabling an nginx jail
fail2ban-regex '2026/11/04 09:15:02 [error] 4242#4242: *1830 limiting requests, excess: 20.840 by zone "perip", client: 192.0.2.77, server: example.com, request: "GET /login HTTP/1.1", host: "example.com"' nginx-limit-reqThe result ends with one summary line; this is the real one from the sample above:
Lines: 193 lines, 104 ignored, 63 matched, 26 missedmatched lines count towards maxretry. ignored lines matched a helper pattern that records the address without counting a failure, such as a closed connection. missed lines matched no rule, which is normal for successful logins and session messages; add --print-all-missed to read them when you expected a match. Zero matched lines on a log full of failures means the wrong filter, log or date format.
Recidive: long bans for repeat offenders
The recidive jail reads fail2ban’s own log and bans addresses that other jails banned 5 times within a day, for a week and on all ports. It overlaps with bantime.increment, but works across jails. It reads a file, so give it backend = auto:
[recidive]
enabled = true
backend = autoAn email for each ban
fail2ban can mail you on a ban, but it sends mail through /usr/sbin/sendmail, so the server needs a mail transfer agent that can deliver or relay to your mailbox. Do not turn it on for sshd, which bans around the clock; on the recidive jail it reports only the persistent cases. Add two lines to the section:
[recidive]
enabled = true
backend = auto
destemail = admin@example.com
action = %(action_mw)saction_mw bans as before and mails a report with the whois data of the address; action_mwl adds the matching log lines. The jail also mails when it starts and stops, so every restart of fail2ban sends one too.
nginx jails: limit-req, botsearch and http-auth
fail2ban ships filters for nginx’s error log (/var/log/nginx/*error.log by default):
nginx-limit-reqbans clients that keep exceeding an nginx rate limit. It needslimit_reqconfigured, or nothing is ever logged.nginx-botsearchbans clients that probe typical attack paths that do not exist on your server, such aswp-login.php, phpMyAdmin or webmail. It reads nginx’sNo such file or directoryerrors, so it only sees paths nginx tries to serve from disk; requests thatproxy_passhands to an app never show up there.nginx-http-authbans wrong passwords for HTTP basic auth.nginx-bad-requestreads the access log and bans clients that cause 400 responses.
# in the http { } block, e.g. /etc/nginx/conf.d/ratelimit.conf
limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;
# in the location you want to protect, e.g. location /login { ... }
limit_req zone=perip burst=20 nodelay;Put limit_req on login, search or API paths rather than the whole server: one page that loads a few dozen assets at once can exceed the burst, and each rejected request is a log line the jail counts.
[nginx-limit-req]
enabled = true
backend = auto
maxretry = 10
[nginx-botsearch]
enabled = true
backend = auto
maxretry = 2
bantime = 1dbackend = auto is the line people miss on Ubuntu 24.04: without it these jails inherit the journal backend, start without a warning and never ban. Behind Cloudflare, nginx logs Cloudflare’s addresses unless you restore the visitor’s IP, and a web jail then bans Cloudflare instead of the attacker. Fix that before you enable any web jail, as shown under Cloudflare error 522.
If nginx runs in a Docker container, its traffic is forwarded, not delivered locally, and a ban in the input chain never sees it. For such a jail use an iptables action in Docker’s own chain: banaction = iptables-multiport plus chain = DOCKER-USER. The jail also needs the container’s log as a file on the host, for example through a mounted log directory, because nginx in a container logs to Docker by default.
When fail2ban does not start or does not ban
| Symptom | Cause | Fix |
|---|---|---|
Failed during configuration: Have not found any log file for sshd jail | No /var/log/auth.log (Debian 12), or a logpath that does not exist | backend = systemd in [sshd], or correct the path |
No module named 'asynchat' | Ubuntu 24.04 with the fail2ban version from the release image | sudo apt update && sudo apt install --only-upgrade fail2ban |
section 'sshd' already exists | The same section twice in one file | Merge the two sections |
Jail runs, Total failed stays 0 | Wrong backend, a web jail on the journal, or the filter does not match your log | Check with fail2ban-client -d and fail2ban-regex |
| Address banned, attacks still arrive | SSH on another port, Docker-published ports, or traffic through Cloudflare | Set port, use DOCKER-USER, restore real IPs |
| You banned yourself | Your IP is not in ignoreip | Unban from the console, then add it |
fail2ban is one layer. Key-only logins with root login off, from the SSH hardening guide, stop password guessing on their own, and a UFW policy that only opens the ports you serve leaves fail2ban less to watch. Which port sshd really uses, and how to test it from outside, is in SSH port 22.
Frequently asked
How do I unban an IP address in fail2ban?
Run 'sudo fail2ban-client set sshd unbanip 203.0.113.10' to unban it from one jail; it prints 1 if the address was banned and 0 if not. 'sudo fail2ban-client unban 203.0.113.10' unbans it from every jail, and 'sudo fail2ban-client banned 203.0.113.10' tells you first which jails hold it. Add the address to ignoreip in jail.local and run 'sudo fail2ban-client reload' so it does not happen again.
Why does fail2ban not start on Debian 12?
Debian 12 no longer writes /var/log/auth.log, but its fail2ban package still points the sshd jail there, so it stops with 'Have not found any log file for sshd jail'. Add backend = systemd to the [sshd] section of /etc/fail2ban/jail.local, check with 'sudo fail2ban-client -t' and restart fail2ban. Debian 13 and Ubuntu read the journal by default.
Should I edit jail.conf or jail.local?
jail.local. jail.conf belongs to the package and is replaced on upgrades. jail.local and .local files in jail.d/ are read after it and only need the settings you change. Copying all of jail.conf into jail.local, as older guides suggest, freezes every default at the old value.