Step 6: Configure all jails
Create /etc/fail2ban/jail.local. This overrides the package defaults and survives updates. All four jails go in one file:
[DEFAULT]
ignoreip = 127.0.0.1/8 ::1 YOUR_SERVER_IP YOUR_HOME_RANGE
# ── SSH brute-force jail ──────────────────────────────────
[sshd]
enabled = true
port = ssh
mode = aggressive
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
findtime = 3600
bantime = 604800
# Incremental banning — doubles the ban duration on repeat offenses
bantime.increment = true
bantime.factor = 2
bantime.formula = ban.Time * (1<<(ban.Count if ban.Count<8 else 8))
bantime.maxtime = 2419200 # 4-week hard cap on exponential growth
action = nftables-multiport[name=sshd, port="ssh", protocol=tcp]
%(action_abuseipdb)s[abuseipdb_category="18,22"]
# ── Nginx forbidden-request jail ──────────────────────────
[nginx-forbidden]
enabled = true
port = http,https
filter = nginx-forbidden
logpath = /var/log/nginx/*error.log
maxretry = 3
findtime = 7200
bantime = 604800
bantime.increment = true
bantime.factor = 2
bantime.formula = ban.Time * (1<<(ban.Count if ban.Count<8 else 8))
bantime.maxtime = 2419200
action = nftables-multiport[name=nginx-forbidden, port="80,443", protocol=tcp]
%(action_abuseipdb)s[abuseipdb_category="19,21"]
# ── Rate-limit jail ───────────────────────────────────────
[nginx-ratelimit]
enabled = true
port = http,https
filter = nginx-ratelimit
logpath = /var/log/nginx/*.access.log
findtime = 120
maxretry = 5
bantime = 86400
action = nftables-multiport[name=nginx-ratelimit, port="80,443", protocol=tcp]
# ── 404-storm jail ────────────────────────────────────────
[nginx-404]
enabled = true
port = http,https
filter = nginx-404
logpath = /var/log/nginx/*.access.log
maxretry = 15
findtime = 120
bantime = 7200
action = nftables-multiport[name=nginx-404, port="80,443", protocol=tcp]
Add your own server IPs and trusted networks to ignoreip — you don't want to ban yourself during testing. Use CIDR notation for ranges (e.g., 2001:db8:abc::/48 for a home IPv6 prefix). The ignoreip in [DEFAULT] applies to all jails; you can override it per jail if needed.
How the incremental banning works:
| Offense | Formula | Ban duration |
|---|---|---|
| 1st | ban.Time * 2 |
~2 weeks |
| 2nd | ban.Time * 4 |
~4 weeks |
| 3rd | ban.Time * 8 |
~8 weeks |
| 8th+ | ban.Time * 256 |
~4 weeks (capped by bantime.maxtime) |
The formula caps at 8 offenses to avoid integer overflow. A repeat offender escalates quickly — two hits in a week puts them at 4 weeks. Three hits, 8 weeks. At 8 hits they're capped at 4 weeks. Without a bantime.maxtime, the formula would grow without bound — reaching ~2.5 years at 8 offenses — which would eventually bloat the nftables set and permanently lock out IPs on dynamic address pools. This punishes persistent bots far more harshly than one-off scanners while giving legitimate users who fat-finger a password plenty of time to recover before their ban expires.
Jail rationale at a glance:
Configuring the PHP rate limit that feeds nginx-ratelimit:
Add a limit_req zone to your php.conf to throttle any PHP request regardless of response code. This catches bots that probe random framework paths (/core/secrets/config%2ejson, /django/settings%2epy) that fall through to your CMS and return 404 through the full PHP stack — consuming a worker and a database query each time.
# In nginx.conf http { } block — alongside existing limit_req_zone definitions
limit_req_zone $binary_remote_addr zone=phpzone:10m rate=5r/s;
# In php.conf
location ~ \.php$ {
limit_req zone=phpzone burst=10 nodelay;
limit_req_status 429;
limit_conn phplimit 5;
try_files $uri =404;
include fastcgi_params;
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
}
5 requests per second with a burst of 10. A legitimate browser loading a page with a few uncached assets stays under the burst. A bot firing 40 sequential probes at 2-second intervals stays under it too — but it can't speed up or go parallel without hitting the wall. The limit_req_status 429 returns HTTP 429 (Too Many Requests) instead of the default 503, giving fail2ban an unambiguous signal.
Why not use a keyword-based 404 filter instead? It's tempting to write a failregex that matches config, settings, or env in 404 paths. Don't. Legitimate visitors hit 404s containing those words — /wp-content/uploads/2023/env-photo.jpg that you deleted, /config-guide/ that someone bookmarked three years ago. The filter can't distinguish a scanner probing /core/secrets/config%2ejson from a real user following a stale link. Rate limiting the PHP handler avoids false positives entirely — it caps everyone equally, and only bots that sustain an unreasonable request rate get banned.
Why the 404-storm jail exists separately: Bots that probe random non-existent paths — /swagger-ui.html, /actuator/env, /sitemap.xml — generate 404s without ever hitting PHP (which would trigger a 429) and without hitting a deny all rule (which would trigger a 403). Real scanners like LeakIX fire off 30+ unique 404s in under a minute. The 404 jail's threshold — 15 unique 404s in 2 minutes — catches these without snagging normal traffic. The 2-hour ban is proportionate: long enough to break a scanner's momentum, short enough to not permanently lock out a legitimate user behind a misconfigured proxy.