fail2ban

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 # fail2ban starts automatically after install. Verify: $ sudo systemctl status fail2ban ● fail2ban.service - Fail2Ban Service Loaded: loaded (/lib/systemd/system/fail2ban.service) Active: active (running) # By default, fail2ban installs but enables NO jails (nothing is being watched yet) $ sudo fail2ban-client status Status |- Number of jail: 0 `- Jail list: # You need to create jail.local to enable jails
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
# /etc/fail2ban/jail.local [DEFAULT] # ── Timing ─────────────────────────────────────────────────────── bantime = 1h findtime = 10m maxretry = 5 # bantime — how long to ban the offending IP. # Default is 10 minutes — far too short for real attacks. # 1h is a sensible minimum; 24h is better for SSH. # # findtime — the sliding window for counting failures. # 5 failures within 10 minutes triggers a ban. # # maxretry — number of failures that triggers a ban. # 5 is reasonable. SSH key auth (Chapter 5) means # legitimate users never fail here at all. # ── Whitelist — IPs that are NEVER banned ──────────────────────── ignoreip = 127.0.0.1/8 ::1 192.168.1.0/24 # IMPORTANT: Add your home network subnet here. # If you mistype your password 6 times while troubleshooting at # 2am, you don't want to lock yourself out. # Replace 192.168.1.0/24 with your actual subnet (check: ip route) # You can also add individual IPs: ignoreip = 127.0.0.1/8 ::1 192.168.1.0/24 203.0.113.5 # ── Action ─────────────────────────────────────────────────────── banaction = iptables-multiport # iptables-multiport is the default — blocks the IP across all ports # (not just the port that triggered the ban). # This is usually what you want: block the whole IP, not just SSH. # ── Backend (how fail2ban reads logs) ──────────────────────────── 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).

# Append to jail.local — below the [DEFAULT] section [sshd] enabled = true port = ssh logpath = %(sshd_log)s backend = %(sshd_backend)s maxretry = 5 bantime = 24h # enabled — activates this jail. Without this, the jail is ignored. # # port — "ssh" expands to port 22. If you moved SSH to another # port (e.g. 2222), use: port = 2222 # # logpath — %(sshd_log)s is a variable defined in paths-common.conf. # On Ubuntu/Debian it resolves to the systemd journal # or /var/log/auth.log depending on the system. # # backend — %(sshd_backend)s uses systemd journal on modern systems. # # bantime — override the global 1h: SSH is critical, use 24h. # A 24-hour ban makes most brute-force attempts futile.
# Save jail.local, then restart fail2ban to apply changes $ sudo systemctl restart fail2ban # Verify the sshd jail is now active $ sudo fail2ban-client status Status |- Number of jail: 1 `- Jail list: sshd # Detailed jail status $ 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 # "Currently failed: 2" — two IPs have recent failures but haven't hit maxretry yet # "Total banned: 8" — 8 bans since fail2ban started (previous ones expired) # "Banned IP list: ..." — this IP is actively banned right now

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.
Without mod_remoteip
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.
With mod_remoteip
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.

Setup Walkthrough · mod_remoteip for Cloudflare Tunnel
Enable mod_remoteip and configure Apache to read real visitor IPs from Cloudflare's 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
# /etc/apache2/conf-available/remoteip.conf RemoteIPHeader CF-Connecting-IP RemoteIPTrustedProxy 127.0.0.1 RemoteIPTrustedProxy ::1 # RemoteIPHeader — which header contains the real client IP. # Cloudflare injects "CF-Connecting-IP" with the # original visitor's IP into every request. # RemoteIPTrustedProxy — only trust the header when the connection # comes from these addresses (the loopback, where # cloudflared runs). An untrusted source could # forge this header — this restriction prevents that.
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 # 82.45.123.67 is your real public IP — not 127.0.0.1. # mod_remoteip is working correctly. # If you still see 127.0.0.1, check the CF-Connecting-IP header is present: $ curl -I https://osztromok.com # Cloudflare should be injecting CF-Connecting-IP for all proxied requests. # If it's missing, check your Cloudflare SSL/TLS mode (Full Strict is correct).
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.

# Append to /etc/fail2ban/jail.local [apache-auth] enabled = true port = http,https logpath = %(apache_error_log)s maxretry = 6 bantime = 1h # Watches Apache error log for authentication failures. # Triggers on things like: "user philip: authentication failure" # (when using password-protected directories with Apache's own auth) [apache-badbots] enabled = true port = http,https logpath = %(apache_access_log)s bantime = 24h maxretry = 2 # Watches access log for known malicious bots and scanners. # The badbots filter has a list of bad User-Agent strings. # maxretry=2 because these should never legitimately visit: ban on second hit. [apache-noscript] enabled = true port = http,https logpath = %(apache_error_log)s maxretry = 6 bantime = 1h # Watches for requests to non-existent scripts — classic exploit scanners. # Examples: GET /cgi-bin/test.cgi, GET /phpMyAdmin/setup.php # Pattern: "File does not exist: /var/www/..." for script file types [apache-overflows] enabled = true port = http,https logpath = %(apache_error_log)s maxretry = 2 bantime = 24h # Watches for request URI/header overflow attempts — buffer overflow probes. # Very low maxretry (2) because legitimate traffic never looks like this.
# Apply the new jails $ sudo systemctl restart fail2ban # Verify all jails are now active $ 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

# ── Checking status ─────────────────────────────────────────────── # Overall status — how many jails, which ones $ sudo fail2ban-client status # Status of a specific jail — shows currently banned IPs and counts $ sudo fail2ban-client status sshd # ── Banning and unbanning ──────────────────────────────────────── # Manually ban an IP in the sshd jail (useful if you spot an attacker in logs) $ sudo fail2ban-client set sshd banip 203.0.113.45 1 # "1" = one IP banned — success # Unban an IP — use this if you accidentally ban yourself $ sudo fail2ban-client set sshd unbanip 192.168.1.5 1 # Unban across ALL jails at once (nuclear unban — use with care) $ sudo fail2ban-client unban 192.168.1.5 # ── Reloading config ───────────────────────────────────────────── # Reload jail.local without a full restart (preserves active bans) $ sudo fail2ban-client reload # Reload a specific jail only $ sudo fail2ban-client reload sshd # Full restart (clears all active bans — use sparingly) $ 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.

# Add to the [DEFAULT] section of jail.local [DEFAULT] # ... existing settings ... bantime = 1h findtime = 10m maxretry = 5 ignoreip = 127.0.0.1/8 ::1 192.168.1.0/24 # ── Escalating bans for repeat offenders ───────────────────────── bantime.increment = true bantime.multiplier = 2 bantime.maxtime = 4w # cap at 4 weeks (not truly permanent, but effectively so) bantime.overalljails = true # count bans across all jails, not just the one that triggered # How this works: # 1st ban: 1h # 2nd ban (same IP returns): 2h # 3rd ban: 4h # 4th ban: 8h # ...continuing until 4w cap # # bantime.overalljails=true means an IP banned from sshd and then # apache-badbots has its total repeat count considered — it doesn't # get a "fresh start" in the second jail.
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

# Watch the fail2ban log in real time $ 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 # "Found" = a log line matched the filter (failure detected) — counting toward maxretry # "Ban" = maxretry hit — iptables rule added, IP is now blocked # To see unban events too (bans expiring): $ sudo grep "Unban\|Ban\|Found" /var/log/fail2ban.log | tail -20 # Count total bans per jail since fail2ban started: $ sudo grep "Ban " /var/log/fail2ban.log | awk '{print $6}' | sort | uniq -c | sort -rn 23 [sshd] 4 [apache-badbots] 1 [apache-noscript] # sshd is the busiest — expected, SSH is the main attack surface

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.

# Test the sshd filter against the auth log — shows what it would detect $ 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 # 49 matched = 49 bad attempts detected. # If this shows 0 matched, the filter isn't reading the log correctly — # check if the log path in jail.local matches where auth.log actually lives. # Test the Apache badbots filter against access log: $ sudo fail2ban-regex /var/log/apache2/access.log /etc/fail2ban/filter.d/apache-badbots.conf

Complete jail.local File

# /etc/fail2ban/jail.local — complete recommended config for this setup [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

CommandPurpose
sudo fail2ban-client statusList active jails and how many are running
sudo fail2ban-client status sshdDetailed status for the sshd jail — banned IPs, total counts
sudo fail2ban-client set sshd unbanip IPManually unban an IP from a specific jail
sudo fail2ban-client unban IPUnban an IP from all jails at once
sudo fail2ban-client set sshd banip IPManually ban an IP in a specific jail
sudo fail2ban-client reloadReload jail.local config without restarting (preserves active bans)
sudo systemctl restart fail2banFull restart — clears active bans, apply new config
sudo tail -f /var/log/fail2ban.logWatch fail2ban activity in real time
sudo fail2ban-regex LOG FILTERTest how many log lines a filter would match
sudo journalctl -u fail2ban -n 50Check fail2ban's own startup/error logs
SettingRecommended valueNotes
bantime (SSH)24hOverride the global in [sshd] — SSH attacks are persistent, make bans count
bantime (Apache)1h–24hShorter for auth jails; 24h for badbots/overflows
findtime10mDefault is fine — aggressive scanners hit the threshold in under a minute
maxretry5Allows some typos; with SSH key auth, legitimate users never fail
ignoreipLAN + loopback127.0.0.1/8 ::1 and your home subnet — never skip this
bantime.incrementtrueEscalating bans punish repeat offenders automatically