How to Protect Nginx and WordPress with Fail2ban (Custom Jails for Web Attacks)

Tutorial 7 set Fail2ban on SSH. A public website gets a different kind of traffic: thousands of POST /wp-login.php guesses, scanners requesting /phpmyadmin, /.env, /wp-config.php.bak and four hundred other paths, and bots failing basic auth on your private pages. Nginx logs every one of them, which means Fail2ban can ban every one of them. Three jails and one custom filter cover almost everything.
Quick reference
| Jail | Catches | Log |
|---|---|---|
| nginx-http-auth | failed basic-auth logins | /var/log/nginx/error.log |
| nginx-botsearch | requests for known scanner paths | /var/log/nginx/access.log |
| nginx-limit-req | clients tripping Nginx rate limits | error.log |
| wordpress-login (custom) | repeated wp-login.php / xmlrpc.php POSTs | access.log |
| nginx-404 (custom) | 404 floods | access.log |
1. The ready-made jails
sudo nano /etc/fail2ban/jail.local
Add to what's there from tutorial 7:
[nginx-http-auth]
enabled = true
port = http,https
logpath = /var/log/nginx/error.log
maxretry = 5
[nginx-botsearch]
enabled = true
port = http,https
logpath = /var/log/nginx/access.log
maxretry = 2
bantime = 1d
[nginx-limit-req]
enabled = true
port = http,https
logpath = /var/log/nginx/error.log
nginx-botsearch bans on the second request for a path from its list of scanner favourites — two is plenty; real users never ask for /phpmyadmin/.
2. A WordPress login filter
Nginx logs a failed WordPress login as a POST /wp-login.php returning 200 (the page re-renders) — the same as a successful one. The practical approach: treat any repeated POST to the login page or xmlrpc.php as a guess.
sudo nano /etc/fail2ban/filter.d/wordpress-login.conf
[Definition]
failregex = ^<HOST> .* "POST /wp-login\.php
^<HOST> .* "POST /xmlrpc\.php
ignoreregex =
[wordpress-login]
enabled = true
port = http,https
filter = wordpress-login
logpath = /var/log/nginx/access.log
maxretry = 5
findtime = 10m
bantime = 6h
Five login POSTs in ten minutes bans for six hours. You log in once and stay logged in; bots try hundreds. If xmlrpc is unused, block it outright in Nginx (location = /xmlrpc.php { deny all; }) — most WordPress attacks go through it.
3. A 404-flood filter
Scanners that don't use the botsearch paths still generate hundreds of 404s:
sudo nano /etc/fail2ban/filter.d/nginx-404.conf
[Definition]
failregex = ^<HOST> .* "(GET|POST|HEAD) [^"]*" 404
ignoreregex = \.(css|js|png|jpg|ico|woff2?)
[nginx-404]
enabled = true
port = http,https
filter = nginx-404
logpath = /var/log/nginx/access.log
maxretry = 30
findtime = 2m
bantime = 1h
Thirty 404s in two minutes is a scanner, not a visitor with a stale bookmark.
4. Apply and test
sudo fail2ban-client -d >/dev/null && echo "config ok"
sudo systemctl restart fail2ban
sudo fail2ban-client status
sudo fail2ban-regex /var/log/nginx/access.log /etc/fail2ban/filter.d/wordpress-login.conf
The last line shows how many lines in the real log would have matched — the quickest way to prove a filter before trusting it. Check the jails a day later:
sudo fail2ban-client status wordpress-login
5. Behind Cloudflare or a proxy
If traffic reaches Nginx through Cloudflare, the logged IP is Cloudflare's and bans would hit Cloudflare. Make Nginx log the real visitor:
# /etc/nginx/conf.d/realip.conf
set_real_ip_from 173.245.48.0/20; # ...all Cloudflare ranges, from their published list
real_ip_header CF-Connecting-IP;
Then Fail2ban sees the real IP. Better still, ban at Cloudflare: Fail2ban's cloudflare action (action = %(action_)s cloudflare[cfuser=..., cftoken=...]) adds a firewall rule in your Cloudflare account so the attacker never reaches you at all.
6. Don't ban yourself
ignoreip = 127.0.0.1/8 192.168.1.0/24 in [DEFAULT], plus your VPN subnet. And test new filters with fail2ban-regex before enabling them — a filter that matches every request bans the whole internet.
Troubleshooting
Jail shows 0 matches on a busy site. Log format differs from the filter's expectation (a custom log_format in nginx.conf). fail2ban-regex with --print-all-missed shows what didn't match.
Legitimate users banned. maxretry too low for a page that POSTs often, or a shared office IP. Raise it, or add their IP to ignoreip.
Bans don't stop the traffic. They're hitting through a proxy IP — see step 5.
Fail2ban slow with large logs. Rotate Nginx logs daily (tutorial 67) and set backend = pyinotify or systemd for efficiency.
Wrapping up
Three built-in jails, two short custom filters, fail2ban-regex to prove them, ignoreip so you're never on the list. The scanners and password-guessers that make up most of a public site's traffic get a six-hour door in the face — and the log gets quiet enough to read.
