fail2ban: Stop Getting Hammered on SSH and Nginx
fail2ban for SSH and nginx on Ubuntu 24.04 / Debian 12-13: jail.local, systemd backend, a custom scanner filter, testing with fail2ban-regex, and gotchas.
Put any box on a public IP and within minutes auth.log fills up with login attempts for root, admin, ubuntu, oracle. Nginx access logs get /wp-login.php, /.env and /.git/config probes on sites that have never run WordPress. None of that is going to get in if SSH is key-only, but it's noise, it burns CPU, and it buries the log lines you actually care about. fail2ban reads the logs and bans the IPs at the firewall. Boring, old, works.
TL;DR: install it, never edit jail.conf, put your settings in jail.local, use backend = systemd for SSH, add ignoreip for your own IP before you test, and verify every filter with fail2ban-regex.
sudo apt install fail2ban python3-systemd
sudo tee /etc/fail2ban/jail.local >/dev/null <<'EOF'
[DEFAULT]
ignoreip = 127.0.0.1/8 ::1 203.0.113.10
bantime = 1h
findtime = 10m
maxretry = 5
bantime.increment = true
bantime.maxtime = 1w
[sshd]
enabled = true
backend = systemd
EOF
sudo systemctl enable --now fail2ban
sudo fail2ban-client status sshd
Replace 203.0.113.10 with the IP you administrate from. Below: what each line does, the nginx jails, and the stuff that goes wrong.
The config layout
/etc/fail2ban/jail.conf— the shipped defaults. Don't edit it. The package owns it and an upgrade will either overwrite your changes or nag you with a dpkg prompt./etc/fail2ban/jail.d/*.conf— distro drop-ins. Debian/Ubuntu shipdefaults-debian.confhere, which already enables thesshdjail./etc/fail2ban/jail.local— yours. Read last, wins./etc/fail2ban/filter.d/— the regexes that decide what counts as a failure.
What the [DEFAULT] values mean:
findtime = 10m,maxretry = 5— five failures within ten minutes gets you banned.bantime = 1h— first ban lasts an hour.bantime.increment = true— repeat offenders get longer bans each time (doubling by default), capped bybantime.maxtime. This is the setting that actually makes the persistent bots go away. Short first ban, so a fat-fingered colleague isn't locked out all day; long bans for IPs that keep coming back.ignoreip— never ban these. Space-separated, CIDR allowed.
SSH: use the systemd backend
[sshd]
enabled = true
backend = systemd
# port = 2222 # only if sshd isn't on 22
With backend = systemd, fail2ban reads from the journal instead of tailing /var/log/auth.log. On Ubuntu 24.04 auth.log exists because rsyslog is installed, so the file backend works too. On a minimal Debian 12/13 install there often is no rsyslog and therefore no auth.log at all — and the sshd jail with a file backend then refuses to start because its log path doesn't exist. The systemd backend works on both, so I just always set it. It needs the python3-systemd module, which is why it's in the install line.
If you moved SSH to another port, set port = accordingly — the ban action only blocks the ports listed, and the default is ssh (22). Ban on the wrong port and the attacker keeps going while the jail claims to have banned them.
Of course fail2ban is the last layer, not the first. Key-only auth and no root login do far more; that's in the SSH hardening checklist.
Nginx jails
fail2ban ships several nginx filters. Two I turn on wherever nginx faces the internet:
[nginx-http-auth]
enabled = true
[nginx-botsearch]
enabled = true
nginx-http-auth— failed HTTP basic auth (reads the error log).nginx-botsearch— requests for typical scanner paths that don't exist (also the error log).
The shipped jails point at /var/log/nginx/error.log by default via the distro path config. If your vhosts log somewhere else, set logpath per jail.
A custom filter for scanners
The built-in filters don't catch the cheap stuff I see most: requests for .env, .git, wp-login.php on sites that aren't WordPress. A filter for the access log:
# /etc/fail2ban/filter.d/nginx-probe.conf
[Definition]
failregex = ^<HOST> -[^"]*"(?:GET|POST|HEAD) /(?:\.env|\.git/|wp-login\.php|xmlrpc\.php|phpmyadmin)[^"]*" (?:403|404|444)
ignoreregex =
# in jail.local
[nginx-probe]
enabled = true
port = http,https
filter = nginx-probe
logpath = /var/log/nginx/access.log
maxretry = 3
bantime = 1d
That regex assumes the standard combined log format (IP - user [date] "METHOD /path HTTP/x" status ...). It only matches when the response was 403/404/444, so if a site does run WordPress, real logins (200/302) don't count. Don't put this jail in front of an actual WordPress site without thinking about it anyway — wp-login.php failures there return 200 and need a different approach.
Test the filter before you trust it
sudo fail2ban-regex /var/log/nginx/access.log /etc/fail2ban/filter.d/nginx-probe.conf
Look at the summary at the bottom: Failregex: N total and how many lines matched. Zero matches on a log you know contains scanner hits means the regex or the log format is wrong. A huge number of matches means it's too greedy and you're about to ban real users. For the SSH jail against the journal:
sudo fail2ban-regex systemd-journal /etc/fail2ban/filter.d/sshd.conf
Then reload and check:
sudo fail2ban-client reload
sudo fail2ban-client status # list of active jails
sudo fail2ban-client status nginx-probe # counters + banned IPs
Daily operations
# who's banned in a jail
sudo fail2ban-client status sshd
# unban (you, from your phone, an hour later)
sudo fail2ban-client set sshd unbanip 198.51.100.7
# manual ban
sudo fail2ban-client set sshd banip 198.51.100.7
# why did it ban someone
sudo grep -E 'Ban|Unban' /var/log/fail2ban.log | tail -20
Gotcha: locking yourself out
The classic: you test the SSH jail by failing logins from your own machine, it works perfectly, and now your IP is banned and you're in the hoster's web console. Put your admin IP (or your VPN range — see WireGuard) into ignoreip first, then test from somewhere else, like a phone hotspot.
If you're already locked out but have a second way in: fail2ban-client set sshd unbanip <your-ip>.
Gotcha: bans behind a reverse proxy or CDN
If nginx sits behind a CDN or another proxy, the access log's first field is the proxy's IP, not the client's. fail2ban will happily ban the proxy, and with it every visitor. Either configure nginx's real_ip module so the log contains the real client IP, or don't run access-log jails on that box. Also: a ban on the origin doesn't stop traffic through a CDN that keeps connecting from its own IPs — at that layer, block at the CDN.
Gotcha: Docker containers ignore the bans
The default ban actions insert rules for the host's INPUT path. Traffic to a published Docker port goes through FORWARD, so a banned IP can still reach your container. If the service you're protecting runs in Docker, the ban has to land in the DOCKER-USER chain:
[nginx-probe]
# ...
banaction = iptables-multiport
chain = DOCKER-USER
That works with the iptables-based actions, hence the explicit banaction (Debian defaults to nftables). One subtlety: in DOCKER-USER the packet is already DNAT'ed, so port = has to match the container port, not the published host port. Same underlying problem as Docker bypassing UFW, covered in installing Docker.
Gotcha: the ban action and your firewall
Ubuntu's defaults use iptables-based actions, recent Debian defaults use nftables (banaction = nftables in defaults-debian.conf). Both work on their own. If you manage the firewall with UFW and want bans visible in ufw status, set banaction = ufw in [DEFAULT] — but it's not required, and mixing things isn't worth it unless you have a reason. What doesn't work is running fail2ban and CrowdSec's firewall bouncer on the same service; pick one enforcer per service. If you're considering the switch, I wrote about CrowdSec as the modern alternative.
Gotcha: fail2ban crashes on start on Ubuntu 24.04
The fail2ban version that originally shipped with 24.04 crashed on startup with a Python error about the missing asynchat module (removed in Python 3.12). A fixed package was published to noble-updates, so on a current system this is gone — but if you see that error, you're on a stale package list: sudo apt update && sudo apt install --only-upgrade fail2ban.
Is it worth it?
On a box where SSH is key-only, fail2ban isn't what keeps you safe. It's log hygiene and a cheap reduction of noise, and for nginx it shuts down the scanner swarm before it gets to anything that might be vulnerable. Five minutes of setup, and the logs become readable again. Combine with a default-deny firewall from UFW firewall rules and it's a solid baseline.