Repository navigation
09 Fail2Ban Web Protection
Most abuse volume on a public web host is scanner noise: /.env, /.git, WordPress probes, PHP shell names, Grafana/Langflow endpoints. This page shows how to turn those requests into reliable jails without banning your own users or your CDN.
| Source | Typical path | What it gives you |
|---|---|---|
| nginx access logs |
/var/www/httpd-logs/*.access.log (ISPmanager), /var/log/nginx/*access.log (manual), one file per vhost |
request line, status, user agent — the best evidence |
| nginx error logs | /var/www/httpd-logs/*.error.log |
Primary script unknown, access forbidden by rule, upstream errors |
| Apache access logs | /var/log/apache2/*access.log |
not used when nginx serves the sites |
The project's default filter expects the combined format:
198.51.100.7 - - [02/Jan/2026:03:04:05 +0000] "GET /.env HTTP/1.1" 404 548 "-" "Mozilla/5.0"
If your panel uses a different format, adjust the regex and test with fail2ban-regex before
enabling.
| Signal | Count |
|---|---|
| Requests matching attack signatures | 42,756 of 96,245 (44%) |
/.env |
1,216 |
/.env.local |
839 |
/@fs/.env |
719 |
/__vite_rsc_findSourceMapURL |
715 |
/.git/config |
388 |
/wp-admin/install.php |
295 |
/.ssh/id_rsa |
186 |
Primary script unknown (non-existent PHP) |
6,176 |
Conclusion: web jails are not optional on a public host — they see more unique IPs than SSH.
/etc/fail2ban/filter.d/nginx-attack-scan.conf:
[Definition]
# Combined log format. fail2ban removes the date token before matching, so the
# date block is skipped with [^"]* instead of being reproduced literally.
# NOTE: %% is an escaped percent sign in fail2ban config files.
failregex = ^<HOST> - - [^"]*"(?:GET|POST|HEAD|PUT|PATCH|OPTIONS|DELETE|PROPFIND|TRACE|CONNECT) [^"]*(?:\.env|\.git/|\.svn/|\.hg/|\.aws/|\.ssh/|\.DS_Store|/@fs/|__vite|/proc/self|/etc/passwd|/id_rsa|wp-admin/install\.php|wlwmanifest\.xml|phpmyadmin|/pma/|adminer|/myadmin|/config\.json|/\.vscode/|sftp\.json|terminal-xhr\.php|file-control\.php|icecoder|api/console/api_server|/api/fs/read|/api/system/fileView|boaform|HNAP|/solr/|/actuator|/owa/|/autodiscover|/ecp/|/manager/html|/cgi-bin/|%%2e%%2e|\.\./)[^"]*"
ignoreregex =/etc/fail2ban/jail.d/99-web-jails.local:
[nginx-attack-scan]
enabled = true
filter = nginx-attack-scan
logpath = /var/www/httpd-logs/*.access.log
port = 80,443
maxretry = 5
findtime = 10m
bantime = 1d
port = 80,443matters. Without it the jail inheritsport = 0:65535and the firewall action bans every port on the address — a false positive then takes the client's SSH and mail with it. This was measured in production.
Do not ban on GET /wp-login.php: that is a legitimate page an administrator opens. Match
only POST requests, with a higher threshold:
# /etc/fail2ban/filter.d/nginx-wp-login.conf
[Definition]
failregex = ^<HOST> - - [^"]*"POST [^"]*wp-login\.php
ignoreregex =[nginx-wp-login]
enabled = true
filter = nginx-wp-login
logpath = /var/www/httpd-logs/*.access.log
port = 80,443
maxretry = 10
findtime = 10m
bantime = 1dMeasured: 34 POST requests in 3 days on a quiet host, 33 of them from one address.
A scanner requesting /<random>.php makes PHP-FPM answer Primary script unknown; nginx writes
it to the error log. This is a very strong signal and produces almost no legitimate noise:
# /etc/fail2ban/filter.d/nginx-php-probe.conf
[Definition]
failregex = ^\s*\[error\] \d+#\d+: \*\d+ FastCGI sent in stderr: "Primary script unknown" while reading response header from upstream, client: <HOST>,
# nginx error log date: 2026/01/02 03:04:05
datepattern = {^LN-BEG}%%ExY(?P<_sep>[-/.])%%m(?P=_sep)%%d[T ]%%H:%%M:%%S(?:[.,]%%f)?(?:\s*%%z)?
{^LN-BEG}
ignoreregex =Note the ^\s*\[error\]: the timestamp is stripped before matching, leaving a leading space.
[nginx-php-probe]
enabled = true
filter = nginx-php-probe
logpath = /var/www/httpd-logs/*.error.log
/var/log/nginx/error.log
port = 80,443
maxretry = 10
findtime = 10m
bantime = 1dMeasured: 6,176 matches; top sources 1,103 / 971 / 936 requests.
If nginx itself denies a path (access forbidden by rule), it is already a confirmed attack:
# built-in filter: nginx-forbidden
[nginx-forbidden]
enabled = true
logpath = /var/www/httpd-logs/*.error.log
maxretry = 10
findtime = 1h
bantime = 1dIf you configure limit_req in nginx, the standard nginx-limit-req filter works on the error
log. It is not enabled by the project: rate-limit thresholds are business decisions and deserve
their own tuning.
# 1. positive test: does the filter match the attack paths?
fail2ban-regex '/var/www/httpd-logs/*.access.log' \
/etc/fail2ban/filter.d/nginx-attack-scan.conf \
| tail -5
# expect: Failregex: <N> total ... matched
# 2. inspect what matched — must be only attack-like URLs
fail2ban-regex '/var/www/httpd-logs/*.access.log' \
/etc/fail2ban/filter.d/nginx-attack-scan.conf \
--print-all-matched | head -40
# 3. negative test: a filter must NOT match ordinary requests
grep -h '"GET / ' /var/www/httpd-logs/*.access.log | head -200 > /tmp/normal.log
fail2ban-regex /tmp/normal.log /etc/fail2ban/filter.d/nginx-attack-scan.conf
# expect: 0 matched
# 4. validate the whole configuration and apply
fail2ban-client -t && systemctl restart fail2ban
# 5. verify the actions are attached
fail2ban-client get nginx-attack-scan actionsMeasured on the reference host: 42,756 matches from 96,244 lines for the access filter; 6,176 for the PHP-probe filter; zero matches on the "normal page" sample.
| Risk | Mitigation |
|---|---|
| WordPress/phpMyAdmin administrators | ban only POST login attempts, threshold 10/10m; add admin IPs to ignoreip
|
| SEO crawlers (Googlebot, Bingbot, Semrush) | they request many 404s; do not ban on generic 404, only on attack signatures; respect ignoreip for known crawler ranges if needed |
| Monitoring / uptime checks | add monitoring IPs to ignoreip
|
| Your offices / partners | add to ignoreip (both jails and [DEFAULT]) |
| CDN edge addresses | see Cloudflare section below |
Application APIs that legitimately poll /api/...
|
narrow the signature list, exclude the app's real paths |
A filter that matches ordinary pages is worse than no filter: it bans customers.
If sites are behind Cloudflare, nginx sees Cloudflare edge IPs, not the real client:
- fail2ban would ban the edge address (and possibly break the site for many users);
- banning a real client IP at the firewall does nothing, because traffic arrives from the edge.
Options:
-
Preferred: enable
ngx_http_realip_moduleso logs (and fail2ban) see the real client:set_real_ip_from 173.245.48.0/20; set_real_ip_from 103.21.244.0/22; # ... all Cloudflare ranges, see https://www.cloudflare.com/ips/ real_ip_header CF-Connecting-IP;
Then use Cloudflare's own firewall/WAF for edge blocking; firewall bans of client IPs will not affect the TCP connection to the edge.
-
Keep the CDN ranges in
[ignore] rangesof the reporter so a CDN edge is never reported. -
If you cannot use real-IP, do not enable web jails that ban on requests: you would only punish the CDN.
The project's example configuration ships with all Cloudflare IPv4/IPv6 ranges in the reporter's
ignore list. That list protects reporting, not the firewall: firewall rules need their own
whitelist (ignoreip).
The examples use a glob (/var/www/httpd-logs/*.access.log). Fail2ban expands the glob when the
jail starts. After adding a new site:
systemctl reload fail2ban
fail2ban-client status nginx-attack-scan | grep "File list"Logs are rotated by the panel; fail2ban follows rotation, but a newly created file is only picked up on reload/restart.
<HOST> matches IPv4 and IPv6. Ensure:
-
ignoreipcontains your IPv6 management prefixes; - each web jail has
port = 80,443(same rule); - the nftables/iptables action supports the family (the
nftablesaction creates separate sets).
- Backup
/etc/fail2ban. - Add filter files; test each with
fail2ban-regexon real logs (positive + negative). - Add jails with
port = 80,443,findtime,maxretry,bantime. -
fail2ban-client -t→ OK. - Restart fail2ban.
- Verify
fail2ban-client get <jail> actionsshowsnftables, abuseipdb-async. - Watch the jail for 24 hours:
fail2ban-client status <jail>, review bans, check for customer complaints, look atnft list table inet f2b-table. - Only then increase bantime / add
recidive.
Next: SSH, Mail, FTP · Verification & Rollback · Agent Prompts: Fail2Ban Setup
Fail2Ban_AbuseIPDB · repository · releases · issues · MIT