> ## Content Index
> Fetch the complete content index at: https://blog.sudowl.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# fail2ban: Stop Getting Hammered on SSH and Nginx
- URL: https://blog.sudowl.com/fail2ban-setup-ssh-nginx/
- Published: 2026-04-02T10:00:00.000Z
- Updated: 2026-10-11T08:47:37.000Z
- Description: 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.
- Author: Lukas
- Tags: Linux, Debian, Ubuntu, Security

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`.

```bash
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 ship `defaults-debian.conf` here, which already enables the `sshd` jail.
- `/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 by `bantime.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

```ini
[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](/ssh-hardening-ubuntu-checklist/).

## Nginx jails

fail2ban ships several nginx filters. Two I turn on wherever nginx faces the internet:

```ini
[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:

```ini
# /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 =

```

```ini
# 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

```bash
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:

```bash
sudo fail2ban-regex systemd-journal /etc/fail2ban/filter.d/sshd.conf

```

Then reload and check:

```bash
sudo fail2ban-client reload
sudo fail2ban-client status              # list of active jails
sudo fail2ban-client status nginx-probe  # counters + banned IPs

```

## Daily operations

```bash
# 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](/wireguard-vpn-server-ubuntu/)) 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:

```ini
[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](/docker-install-ubuntu/).

## 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](/crowdsec-modern-fail2ban-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](/ufw-firewall-rules-ubuntu/) and it's a solid baseline.

---

### Related posts

- [Linux Log Rotation with logrotate — Before Your Disk Fills Up](https://blog.diengdoh.com/logrotate-linux-log-rotation/?ref=blog.sudowl.com)
- [Linux User Management — adduser, Groups, sudo and SSH Keys](https://blog.diengdoh.com/linux-user-management-adduser-sudo-ssh-keys/?ref=blog.sudowl.com)
- [Set Up Automatic Security Updates on Ubuntu and Debian](https://blog.diengdoh.com/ubuntu-automatic-updates-unattended-upgrades/?ref=blog.sudowl.com)