Chapter 4 — fail2ban
Every server exposed to the internet is scanned continuously. Automated bots try common passwords against SSH around the clock, and web crawlers probe Apache for known vulnerabilities. UFW (Chapter 3) blocked the network ports you don't need. fail2ban handles the ports you do need open — specifically SSH — by watching logs and automatically banning IPs that show attack patterns. A failed SSH attempt is not a problem; six failed attempts from the same IP in thirty seconds is.
What this chapter covers: How fail2ban works (jail → filter → action model). Installing fail2ban and creating jail.local (the safe override file). DEFAULT section settings — bantime, findtime, maxretry, ignoreip. SSH jail configuration. The Cloudflare Tunnel IP problem: why Apache jails will ban Cloudflare's IPs and break your site without mod_remoteip. Fixing that with mod_remoteip. Apache jails once logging is correct. Managing bans with fail2ban-client. Persistent increasing bans. Monitoring logs. Testing filters.
What fail2ban Does
Without fail2ban — an attacker can try passwords indefinitely:
Attacker ──▶ SSH :22 ──▶ "Failed password for root" ← attempt 1
Attacker ──▶ SSH :22 ──▶ "Failed password for root" ← attempt 2
Attacker ──▶ SSH :22 ──▶ "Failed password for admin" ← attempt 3
Attacker ──▶ SSH :22 ──▶ "Failed password for philip" ← attempt 4
Attacker ──▶ SSH :22 ──▶ "Failed password for philip" ← attempt 5
Attacker ──▶ SSH :22 ──▶ continues for hours...
With fail2ban (maxretry=5, findtime=10m, bantime=1h):
Attacker ──▶ SSH :22 ──▶ "Failed password for root" ← attempt 1
Attacker ──▶ SSH :22 ──▶ "Failed password for root" ← attempt 2
Attacker ──▶ SSH :22 ──▶ "Failed password for admin" ← attempt 3
Attacker ──▶ SSH :22 ──▶ "Failed password for philip" ← attempt 4
Attacker ──▶ SSH :22 ──▶ "Failed password for philip" ← attempt 5 ← THRESHOLD HIT
fail2ban ──▶ iptables ──▶ DROP all from 45.33.32.156 ← IP banned for 1 hour
Attacker ──▶ SSH :22 ──▶ no response (connection dropped silently)
fail2ban doesn't change the password; it just makes a brute-force attack too slow to succeed. With a one-hour ban and only 5 attempts before triggering it, an attacker would need years to try a typical wordlist — and each ban resets their progress.
The Jail → Filter → Action Model
Component
Jail
A named config block that ties everything together. Each jail watches one service (sshd, apache, etc.). The jail says which log file to watch, which filter to apply, and which action to take. You enable or disable jails in jail.local.
Component
Filter
A file of regex patterns in /etc/fail2ban/filter.d/ that match malicious log lines. When Apache logs "File does not exist: /etc/passwd", the apache-noscript filter recognises the pattern. Filters are already provided for most common services.
Component
Action
What happens when maxretry is reached. The default action adds an iptables DROP rule for the offending IP. A UFW action is also available. Actions can optionally send email alerts.
Component
Backend
How fail2ban watches the log file. systemd reads from the systemd journal (faster, works without log files). auto picks the best available method. Use backend = systemd for SSH on modern Ubuntu/Debian.
fail2ban reads the log ──▶ runs it through the filter (regex)
──▶ counts matches per source IP
──▶ when count hits maxretry within findtime:
──▶ calls the action (add iptables rule)
──▶ logs the ban to /var/log/fail2ban.log
──▶ starts bantime countdown
──▶ when bantime expires: removes the iptables rule
Installing fail2ban
$ sudo apt install fail2ban -y
$ sudo systemctl status fail2ban
● fail2ban.service - Fail2Ban Service
Loaded: loaded (/lib/systemd/system/fail2ban.service)
Active: active (running)
$ sudo fail2ban-client status
Status
|- Number of jail: 0
`- Jail list:
Never edit /etc/fail2ban/jail.conf — it's overwritten by package upgrades. Create /etc/fail2ban/jail.local instead. Any setting in jail.local overrides the same setting in jail.conf. You only need to include the settings you want to change — you don't have to copy the entire file.
Creating jail.local — The DEFAULT Section
The [DEFAULT] section sets global values that apply to every jail unless overridden at the jail level. Get these right first — they're the most important settings.
$ sudo nano /etc/fail2ban/jail.local
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
ignoreip = 127.0.0.1/8 ::1 192.168.1.0/24
banaction = iptables-multiport
backend = auto
bantime
= 1h
How long the ban lasts. Accepts: s (seconds), m (minutes), h (hours), d (days). Default is 10m — too short. Start at 1h; escalate to 24h once you're confident.
findtime
= 10m
The sliding window. Failures older than this don't count toward the maxretry threshold. 10m is reasonable — an attacker making 1 attempt per minute is clearly malicious.
maxretry
= 5
Number of failures before a ban. 5 is the default — allows for a few typos. With SSH key auth (Ch.5), legitimate logins never fail at all, so you could reduce this to 3.
ignoreip
= 127.0.0.1/8 ::1 LAN
Space-separated list of IPs and CIDRs that are never banned, regardless of failure count. Always include loopback (127.0.0.1/8 ::1) and your home subnet.
The SSH Jail
Add the SSH jail directly in jail.local below the DEFAULT section. SSH is the most important jail to have — your SSH port is the only port genuinely exposed to the internet (via the Dynamic DNS A record from Chapter 7 of the hosting course).
[sshd]
enabled = true
port = ssh
logpath = %(sshd_log)s
backend = %(sshd_backend)s
maxretry = 5
bantime = 24h
$ sudo systemctl restart fail2ban
$ sudo fail2ban-client status
Status
|- Number of jail: 1
`- Jail list: sshd
$ sudo fail2ban-client status sshd
Status for the jail: sshd
|- Filter
| |- Currently failed: 2
| |- Total failed: 47
| `- Journal matches: _SYSTEMD_UNIT=sshd.service + _COMM=sshd
|- Actions
|- Currently banned: 1
|- Total banned: 8
`- Banned IP list: 45.33.32.156
The Cloudflare Tunnel IP Problem — Read This Before Adding Apache Jails
Important: Apache jails will break your site without mod_remoteip
When a visitor hits osztromok.com, the request travels: visitor → Cloudflare → cloudflared (on your server) → localhost → Apache.
Apache sees the connection arriving from 127.0.0.1 (the loopback address, because cloudflared connects locally). It logs 127.0.0.1 as the client IP in its access log.
If you enable an Apache fail2ban jail without fixing this, fail2ban reads those Apache logs, sees repeated requests from 127.0.0.1, and bans it. 127.0.0.1 is the loopback address — banning it breaks the Cloudflare Tunnel, which takes down your entire website.
The fix is mod_remoteip: it tells Apache to read the real visitor IP from the CF-Connecting-IP header that Cloudflare inserts into every request, and use that as the logged client address. Then Apache logs real IPs, and fail2ban bans real attackers.
Apache logs: 127.0.0.1 for every visitor.
fail2ban sees: all requests from 127.0.0.1.
After N failures: bans 127.0.0.1.
Result: Cloudflare Tunnel broken, site offline.
Apache logs: real visitor IPs (from CF-Connecting-IP).
fail2ban sees: requests from 45.33.32.156, etc.
After N failures: bans that real attacker IP.
Result: Attacker blocked, tunnel unaffected.
Fixing Apache Logging with mod_remoteip
mod_remoteip replaces the REMOTE_ADDR (the IP Apache thinks the connection came from) with the IP from a trusted header. For Cloudflare Tunnel, the real visitor IP is in the CF-Connecting-IP header.
1
Enable mod_remoteip.
$ sudo a2enmod remoteip
Enabling module remoteip.
To activate the new configuration, you need to run:
service apache2 restart
2
Create a mod_remoteip config file. This tells Apache to trust the loopback address (where cloudflared connects) and use the
CF-Connecting-IP header for the real IP.
$ sudo nano /etc/apache2/conf-available/remoteip.conf
RemoteIPHeader CF-Connecting-IP
RemoteIPTrustedProxy 127.0.0.1
RemoteIPTrustedProxy ::1
3
Enable the config and restart Apache.
$ sudo a2enconf remoteip
$ sudo systemctl restart apache2
4
Verify Apache now logs real IPs. Visit your website from outside, then check the access log.
$ sudo tail -5 /var/log/apache2/access.log
82.45.123.67 - - [15/Jun/2026:10:22:14 +0000] "GET / HTTP/1.1" 200 4821
$ curl -I https://osztromok.com
Once mod_remoteip is active, all Apache logs — access, error, and virtual host logs — will show real visitor IPs. This also improves analytics tools that read these logs.
Apache Jails
With mod_remoteip logging real IPs, Apache jails are now safe to enable. Add these to jail.local after the sshd section.
[apache-auth]
enabled = true
port = http,https
logpath = %(apache_error_log)s
maxretry = 6
bantime = 1h
[apache-badbots]
enabled = true
port = http,https
logpath = %(apache_access_log)s
bantime = 24h
maxretry = 2
[apache-noscript]
enabled = true
port = http,https
logpath = %(apache_error_log)s
maxretry = 6
bantime = 1h
[apache-overflows]
enabled = true
port = http,https
logpath = %(apache_error_log)s
maxretry = 2
bantime = 24h
$ sudo systemctl restart fail2ban
$ sudo fail2ban-client status
Status
|- Number of jail: 5
`- Jail list: apache-auth, apache-badbots, apache-noscript, apache-overflows, sshd
Cloudflare's own bot protection vs. fail2ban: Cloudflare already blocks most bots before they reach your server. The Apache jails catch anything that gets through. Think of Cloudflare's firewall as the outer wall and fail2ban as the inner guard — both doing their jobs at different layers.
Managing Bans with fail2ban-client
$ sudo fail2ban-client status
$ sudo fail2ban-client status sshd
$ sudo fail2ban-client set sshd banip 203.0.113.45
1
$ sudo fail2ban-client set sshd unbanip 192.168.1.5
1
$ sudo fail2ban-client unban 192.168.1.5
$ sudo fail2ban-client reload
$ sudo fail2ban-client reload sshd
$ sudo systemctl restart fail2ban
If you ban yourself and can't SSH in: You need physical access to the server (or a local terminal session that's already open). From there, run sudo fail2ban-client set sshd unbanip YOUR-IP. Check your current external IP with curl api.ipify.org from another device. To avoid this: always keep your home subnet in ignoreip.
Persistent Increasing Bans
By default, bans expire after bantime. An attacker who returns after each ban is simply working at a slower pace. fail2ban's bantime.increment feature makes the punishment escalate with each repeat offence — 1 hour becomes 2, then 4, then 8, until it's effectively permanent.
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
ignoreip = 127.0.0.1/8 ::1 192.168.1.0/24
bantime.increment = true
bantime.multiplier = 2
bantime.maxtime = 4w
bantime.overalljails = true
Check the database of repeat offenders: fail2ban stores ban history in a SQLite database at /var/lib/fail2ban/fail2ban.sqlite3. The fail2ban-client status sshd command doesn't show this history, but persistent bans use it internally to decide the escalated ban duration.
Monitoring fail2ban Logs
$ sudo tail -f /var/log/fail2ban.log
2026-06-15 03:22:14,891 fail2ban.filter [INFO] [sshd] Found 45.33.32.156 - 2026-06-15 03:22:14
2026-06-15 03:22:31,204 fail2ban.filter [INFO] [sshd] Found 45.33.32.156 - 2026-06-15 03:22:31
2026-06-15 03:22:44,891 fail2ban.filter [INFO] [sshd] Found 45.33.32.156 - 2026-06-15 03:22:44
2026-06-15 03:22:57,012 fail2ban.filter [INFO] [sshd] Found 45.33.32.156 - 2026-06-15 03:22:57
2026-06-15 03:23:08,445 fail2ban.filter [INFO] [sshd] Found 45.33.32.156 - 2026-06-15 03:23:08
2026-06-15 03:23:08,571 fail2ban.actions [NOTICE] [sshd] Ban 45.33.32.156
$ sudo grep "Unban\|Ban\|Found" /var/log/fail2ban.log | tail -20
$ sudo grep "Ban " /var/log/fail2ban.log | awk '{print $6}' | sort | uniq -c | sort -rn
23 [sshd]
4 [apache-badbots]
1 [apache-noscript]
Testing Filters
If fail2ban isn't triggering bans when you expect it to, test the filter directly against the log file to see what it matches — and what it misses.
$ sudo fail2ban-regex /var/log/auth.log /etc/fail2ban/filter.d/sshd.conf
Running tests
=============
Use failregex filter file : sshd, basedir: /etc/fail2ban
Use log file : /var/log/auth.log
Use encoding : UTF-8
Results
=======
Failregex: 49 total
|- #) [# of hits] regular expression
| 1) [24] Failed ...
| 2) [15] Invalid user ...
| 3) [10] Connection closed ...
Ignoreregex: 0 total
Date template hits:
|- [# of hits] date format
| [194] {Day}/{Month}/{Year}:{Hour}:{Minute}:{Second} ...
Lines: 194 lines, 0 ignored, 49 matched, 145 missed
$ sudo fail2ban-regex /var/log/apache2/access.log /etc/fail2ban/filter.d/apache-badbots.conf
Complete jail.local File
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
ignoreip = 127.0.0.1/8 ::1 192.168.1.0/24
banaction = iptables-multiport
backend = auto
bantime.increment = true
bantime.multiplier = 2
bantime.maxtime = 4w
bantime.overalljails = true
[sshd]
enabled = true
port = ssh
logpath = %(sshd_log)s
backend = %(sshd_backend)s
maxretry = 5
bantime = 24h
[apache-auth]
enabled = true
port = http,https
logpath = %(apache_error_log)s
maxretry = 6
bantime = 1h
[apache-badbots]
enabled = true
port = http,https
logpath = %(apache_access_log)s
bantime = 24h
maxretry = 2
[apache-noscript]
enabled = true
port = http,https
logpath = %(apache_error_log)s
maxretry = 6
bantime = 1h
[apache-overflows]
enabled = true
port = http,https
logpath = %(apache_error_log)s
maxretry = 2
bantime = 24h
Reminder: The Apache jails require mod_remoteip to be configured first (see the mod_remoteip section above). Without it, the jails will ban 127.0.0.1 and take down the Cloudflare Tunnel.
Troubleshooting
fail2ban won't start — "ERROR: Failed to parse" or similar
Syntax error in jail.local. Check: sudo systemctl status fail2ban for the error line, or sudo journalctl -u fail2ban -n 50. Common causes: missing space before the = in a key=value pair, mismatched section headers (typo in [sshd]), or invalid time format (use 1h not 1 hour).
Jail is active but IPs are not being banned
Run sudo fail2ban-regex /path/to/logfile /etc/fail2ban/filter.d/filtername.conf to confirm the filter matches the log. If it shows "0 matched", either the log path is wrong (check with sudo fail2ban-client get sshd logpath), or the log format doesn't match the filter regex. Also check: is the log file actually being written to? sudo tail /var/log/auth.log — if it's empty, SSH may be logging to the systemd journal instead (set backend = systemd).
Apache jails are triggering on 127.0.0.1 — site goes down
mod_remoteip is not configured, or the Apache log still shows 127.0.0.1. Check: sudo tail /var/log/apache2/access.log — do you see real IPs or 127.0.0.1? If still 127.0.0.1: verify a2enconf remoteip was run, the config file is correct, and Apache was restarted (not just reloaded). Temporarily disable the Apache jails while fixing: sudo fail2ban-client stop apache-badbots.
I banned myself and can't SSH in
You need a way to run commands on the server without SSH: (1) physical access — keyboard and monitor directly on the server. (2) If you have another active SSH session open, use it immediately. From there: find your external IP on another device (curl api.ipify.org) and run sudo fail2ban-client set sshd unbanip YOUR.IP.HERE. To prevent this in future: ensure your home subnet is in ignoreip in jail.local.
fail2ban-client status shows jail but no bans — is it working?
Probably yes — if your SSH is well-configured (key auth only, sensible password) and the scanning bots haven't found you yet, there may just not be enough failures to trigger a ban. Check if there are recent "Found" entries (failures detected but not yet at maxretry threshold): sudo grep Found /var/log/fail2ban.log | tail -20. If you see "Found" entries, it's working — just no ban threshold reached yet. You can test by deliberately failing SSH login from another IP.
Getting "ERROR jail 'xxx' does not exist" from fail2ban-client
The jail name in the command doesn't match the section header in jail.local. Jail names are case-sensitive. Run sudo fail2ban-client status to see the exact names of active jails, then use those names in subsequent commands.
Quick Reference — Chapter 4
| Command | Purpose |
| sudo fail2ban-client status | List active jails and how many are running |
| sudo fail2ban-client status sshd | Detailed status for the sshd jail — banned IPs, total counts |
| sudo fail2ban-client set sshd unbanip IP | Manually unban an IP from a specific jail |
| sudo fail2ban-client unban IP | Unban an IP from all jails at once |
| sudo fail2ban-client set sshd banip IP | Manually ban an IP in a specific jail |
| sudo fail2ban-client reload | Reload jail.local config without restarting (preserves active bans) |
| sudo systemctl restart fail2ban | Full restart — clears active bans, apply new config |
| sudo tail -f /var/log/fail2ban.log | Watch fail2ban activity in real time |
| sudo fail2ban-regex LOG FILTER | Test how many log lines a filter would match |
| sudo journalctl -u fail2ban -n 50 | Check fail2ban's own startup/error logs |
| Setting | Recommended value | Notes |
| bantime (SSH) | 24h | Override the global in [sshd] — SSH attacks are persistent, make bans count |
| bantime (Apache) | 1h–24h | Shorter for auth jails; 24h for badbots/overflows |
| findtime | 10m | Default is fine — aggressive scanners hit the threshold in under a minute |
| maxretry | 5 | Allows some typos; with SSH key auth, legitimate users never fail |
| ignoreip | LAN + loopback | 127.0.0.1/8 ::1 and your home subnet — never skip this |
| bantime.increment | true | Escalating bans punish repeat offenders automatically |