Any server with a public address gets probed continuously. SSH sees credential guessing within minutes of the address going live, and web login forms get worked through with stuffing lists. Fail2Ban reads the logs, spots the pattern, and drops a firewall rule in front of whoever is responsible.
This guide covers a working Fail2Ban setup on Ubuntu 24.04 LTS: how the pieces fit together, the jails that matter on a web server, the settings most tutorials get wrong, and how to avoid banning yourself. Where 22.04 LTS differs, that is called out.
How Fail2Ban decides who to block
Three concepts do all the work. A filter is a set of regular expressions that recognise an abusive line in a log. An action is what happens on a match, normally inserting a firewall rule. A jail ties a filter to a log file, a threshold and an action.
When a jail sees more matches from one address than maxretry allows inside the findtime window, the action fires and the address is blocked for bantime. The rule is removed automatically when the ban expires. That loop runs as a service and needs no attention once the thresholds are right.
Out of the box only the SSH jail is enabled. Web server logins, application brute force, scanner traffic and CMS-specific patterns all need jails you configure yourself. Fail2Ban is one layer, and it works best on a machine that has already had the Ubuntu server hardening checklist applied to it.
Installing on Ubuntu
sudo apt update
sudo apt install fail2ban
sudo systemctl enable --now fail2ban
fail2ban-client version
Record the version. Configuration keys have moved between the 0.11 and 1.x series, and a snippet written for the older release can be silently ignored rather than rejected.
The shipped configuration lives in /etc/fail2ban/jail.conf. Editing it is a mistake, because apt replaces the file on upgrade and takes your work with it. Everything you write goes in /etc/fail2ban/jail.local, or in separate files under /etc/fail2ban/jail.d/, both of which are read afterwards and left alone.
The DEFAULT section and what each key does
[DEFAULT] sets the baseline that every jail inherits unless it says otherwise.
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
bantime.increment = true
bantime.factor = 2
bantime.maxtime = 5w
ignoreip = 127.0.0.1/8 ::1 203.0.113.50
banaction = iptables-multiport
destemail = alerts@example.com
sender = fail2ban@example.com
Time values accept suffixes in current releases, so 1h and 10m are clearer than raw seconds and mean the same thing. maxretry counts failures, findtime is the window they must fall inside, and bantime is how long the block lasts.
The pair worth thinking about is bantime against findtime. A ban shorter than the detection window achieves very little, because the attacker is back before the counter has even reset. An hour is a sensible floor for anything that is not a test.
bantime.increment is the setting that changed how this is done. With it enabled, each repeat ban is multiplied by bantime.factor, so a persistent scanner climbs from an hour to a day to a week without you needing an aggressive first ban that catches your own colleagues. bantime.maxtime caps the top of that curve.
Choosing the right ban action
banaction decides how the block is enforced, and the default is not always the right fit. Ubuntu 24.04 runs iptables through the nftables backend, so the classic action still works, but there are two alternatives worth knowing.
| banaction | Use when |
|---|---|
iptables-multiport | Default. Works on any modern Ubuntu through the nft compatibility layer. |
nftables-multiport | The firewall is managed natively with nftables and you want the rules in the same ruleset. |
ufw | UFW manages the firewall, and you want bans visible in ufw status alongside your own rules. |
Picking ufw keeps everything in one place, which makes diagnosis easier six months later when somebody asks why one address cannot reach the site. The relationship between the two tools is covered in the notes on UFW firewall rules for a web server.
Whichever you choose, confirm it covers IPv6. A server with an AAAA record and an IPv4-only ban action is protected against half the internet.
sudo iptables -S | grep f2b
sudo ip6tables -S | grep f2b
The SSH jail, and the log file that may not exist
SSH is the most probed service on any public machine. Key-only authentication already defeats password guessing, so the jail is there to stop the same addresses consuming connection slots and burying real events in the journal.
[sshd]
enabled = true
port = 2222
mode = aggressive
backend = systemd
maxretry = 3
findtime = 10m
bantime = 1h
Three of those lines deserve explanation.
port has to match reality. If SSH has been moved off 22, and on a hardened server it often has, leaving port = ssh here means the ban rule blocks port 22 while the attacker carries on knocking at 2222.
mode = aggressive switches the shipped sshd filter from matching only clear authentication failures to also catching connection attempts that abort early, which is what most scanners actually produce. The available modes are listed at the top of /etc/fail2ban/filter.d/sshd.conf.
backend = systemd is the one that catches people out on 24.04. Minimal Ubuntu images no longer install rsyslog, so /var/log/auth.log may not exist at all, and a jail pointed at a missing file reports itself as running while watching nothing. Reading the journal directly avoids the problem entirely. The backend needs the python3-systemd binding, which the Ubuntu package recommends but a minimal install can skip.
sudo apt install python3-systemd
ls -l /var/log/auth.log 2>/dev/null || echo "no auth.log on this system"
Test the filter against real data before trusting it. fail2ban-regex reports how many lines matched and which ones.
sudo fail2ban-regex systemd-journal /etc/fail2ban/filter.d/sshd.conf
sudo fail2ban-regex /var/log/auth.log /etc/fail2ban/filter.d/sshd.conf
Zero matches against a log you know contains failed logins means the filter does not fit your OpenSSH version's output, and the jail is decorative until you fix it. SSH configuration that reduces the attack surface in the first place is covered in the guide to securing SSH on Ubuntu.
Apache and nginx jails
Enable only what applies to the software you actually run. A jail watching a log file that a different web server writes is wasted configuration.
HTTP basic authentication
Staging sites, admin panels and internal tools behind basic auth are worth protecting, and both web servers log the failures to their error log.
[apache-auth]
enabled = true
port = http,https
logpath = %(apache_error_log)s
maxretry = 3
[nginx-http-auth]
enabled = true
port = http,https
logpath = %(nginx_error_log)s
maxretry = 3
The %(apache_error_log)s style references come from paths-debian.conf and resolve to the right location for the distribution, which is safer than typing a path that changes between releases.
Scanner and bot traffic
The nginx-botsearch and apache-noscript filters catch addresses requesting long runs of paths that do not exist, which is what a vulnerability scanner looks like from the server's side.
[nginx-botsearch]
enabled = true
port = http,https
logpath = %(nginx_access_log)s
maxretry = 2
bantime = 2h
[apache-noscript]
enabled = true
port = http,https
logpath = %(apache_access_log)s
maxretry = 10
bantime = 2h
These two need care. nginx-botsearch matches requests for paths such as /wp-admin and /phpmyadmin, so on a site that legitimately serves WordPress admin those are your own users being banned. Either leave it off for that site, or add your office range to ignoreip and raise the threshold. The apache-noscript threshold of 10 suits a site with predictable URLs and is too tight for one with a lot of legitimate 404 traffic.
ModSecurity
Where ModSecurity is already running, its audit log gives Fail2Ban a much better signal than raw access logs, because the rules have done the interpretation for you. Web server configuration that complements this is covered in the notes on Apache configuration settings.
[apache-modsecurity]
enabled = true
port = http,https
logpath = /var/log/apache2/modsec_audit.log
maxretry = 5
Writing a filter for your own application
Most PHP applications write their own authentication log rather than pushing failures into the web server error log. Protecting them means a custom filter, and the only reliable way to write one is against real log lines.
Take an actual failure line from the application.
2026-03-11 14:23:11 ERROR 198.51.100.24 Failed login for user admin
Then write a filter that matches it, saved as /etc/fail2ban/filter.d/myapp-auth.conf. The <HOST> token is what Fail2Ban replaces with its own address-matching pattern, and every failregex needs exactly one.
[Definition]
failregex = ^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} ERROR <HOST> Failed login for user \S+$
ignoreregex =
Test it, read the match count, and only then wire up the jail.
sudo fail2ban-regex /var/www/example/storage/logs/auth.log \
/etc/fail2ban/filter.d/myapp-auth.conf --print-all-matched
[myapp-auth]
enabled = true
port = http,https
filter = myapp-auth
logpath = /var/www/example/storage/logs/auth.log
maxretry = 5
One caveat on any log-based jail behind a proxy or CDN. If requests arrive through Cloudflare or a load balancer, every line carries the proxy's address, and a match will ban the proxy for all of your visitors at once. Configure the web server to log the real client address first, then point the jail at that.
WordPress login and XML-RPC
WordPress does not log failed logins anywhere useful, so the practical approach is to match POST requests to the login endpoints in the access log and accept that some legitimate logins are counted too. Keep maxretry generous for that reason.
[Definition]
failregex = ^<HOST> .* "POST /(wp-login\.php|xmlrpc\.php).*" 200
ignoreregex =
[wordpress-login]
enabled = true
port = http,https
filter = wordpress-login
logpath = %(nginx_access_log)s
maxretry = 6
bantime = 2h
If nothing on the site uses XML-RPC, and on most current sites nothing does, blocking xmlrpc.php outright at the web server is better than watching it. A request that never reaches PHP costs nothing to refuse.
Whitelisting without opening a hole
ignoreip exempts addresses from every jail. Keep it narrow.
[DEFAULT]
ignoreip = 127.0.0.1/8 ::1 203.0.113.50 2001:db8::/64
Adding 10.0.0.0/8 because a colleague suggested it exempts sixteen million addresses and, on a cloud provider with shared private networking, may exempt other tenants as well. List the specific office address, the VPN endpoint or the jump host, and nothing else.
A dynamic home address cannot be whitelisted safely, since tomorrow it belongs to somebody else. The workable answer there is key-only SSH plus a slightly higher maxretry, rather than an allowlist that drifts.
Bans that survive a restart
Older guides warn that bans are held in memory and lost when the service restarts. That has not been true for a long time. Current Fail2Ban keeps state in a SQLite database and restores active bans on start, and the relevant settings are already in jail.conf.
dbfile = /var/lib/fail2ban/fail2ban.sqlite3
dbpurgeage = 7d
dbpurgeage decides how much history is retained, and it needs to be at least as long as your longest bantime. Setting a five week maximum ban while purging the database after one day throws away the record the increment logic depends on.
The recidive jail is the older approach to the same problem, watching Fail2Ban's own log for addresses banned repeatedly across any jail. With bantime.increment enabled it is largely redundant for single-jail offenders, though it still adds value where an address is caught by several different jails in turn.
Checking that it is actually working
A Fail2Ban installation that is running but matching nothing looks identical to one that is protecting you, right up until it does not.
sudo fail2ban-client status
sudo fail2ban-client status sshd
sudo fail2ban-client get sshd banned
journalctl -u fail2ban --since "24 hours ago"
The jail status shows the number of lines the filter has matched and the currently banned addresses. A jail that has been enabled for a week with zero matched entries is telling you the filter or the log path is wrong, not that the internet has gone quiet.
Learn the unban command before you need it, ideally somewhere you can reach from a phone.
sudo fail2ban-client set sshd unbanip 198.51.100.42
sudo fail2ban-client unban --all
Monitor the service itself as well. A simple check that runs fail2ban-client ping and alerts on failure covers the case where the service has died quietly after a configuration error.
Email notifications
Ban notifications are useful early on, when you are still tuning thresholds and want to see what is being caught.
action = %(action_mwl)s
That action bans the address and emails you the whois record along with the log lines that triggered it. It needs a working mail transfer agent on the server, and without one the mail fails silently while the ban still applies. On a busy server the volume becomes unhelpful within days, so most people move to %(action_)s and read the journal instead once the configuration has settled.
Mistakes that cost more than they prevent
Setting maxretry = 1 looks decisive and locks out an entire office the moment one person mistypes a passphrase, because they all share a public address behind the same NAT gateway.
Enabling scanner jails without checking your own traffic is the next most common. If the site serves the paths those filters treat as suspicious, the first people banned are customers.
Setting a ban of a few minutes wastes the effort. The attacker reconnects as soon as it lifts, and all you have done is add log noise.
Turning Fail2Ban off after locking yourself out is the response that does the most damage. The fix is ignoreip, a higher threshold, and a second way into the machine, not removing the protection.
And leaving a jail pointed at a log file that does not exist is the failure nobody notices, because everything reports as healthy. Check the matched-line count, not the service status.
Where Fail2Ban stops helping
Fail2Ban reads logs on one machine and reacts after the fact. That shape has limits worth being clear about.
It cannot absorb a volumetric denial of service attack, because the traffic arrives faster than logs can be parsed and the machine is saturated regardless of firewall rules. It does little against a distributed attack spread across thousands of addresses, each making only a handful of attempts. And it cannot see anything that does not reach a log, which includes attacks terminated at a CDN.
Those cases need something in front of the server. The trade-offs between the options are set out in the comparison of web application firewalls.
Application-level controls matter too. Rate limiting inside the application, multi-factor authentication on admin logins, and a challenge after repeated failures all reduce the value of a successful guess. Fail2Ban handles the steady background abuse that every server receives, and it does that well.
Keeping the configuration honest over time
Fail2Ban configurations rot quietly. A web server upgrade changes a log format, a filter stops matching, and nothing reports a problem because the service is still running. A few minutes each quarter running fail2ban-regex against recent logs catches that before it matters.
Review the ban list occasionally as well. Patterns in it tell you what is being targeted, and an endpoint appearing repeatedly may be something you did not realise was exposed.
If you would rather have someone review an existing setup, confirm the jails are matching real traffic, and check it against the rest of the server's configuration, N. Cristea can look over it and report what is working. Get in touch with the Ubuntu version, the web server, and a note on what the machine runs.