fail2ban: banning brute-force attackers from your logs
Watch logs, count failures, ban the IP: simple intrusion prevention for SSH and nginx.
In 30 seconds
Every public server is probed by bots trying passwords and scanning for known vulnerable URLs, all day long. fail2ban reads your logs, notices an IP failing again and again, and blocks that IP in the firewall for a while. It is cheap, simple and effective against noisy single-source attacks, but it is one layer among several, not a complete defence.
Why I use it
- I run fail2ban on my servers because it quietly removes most of the brute-force noise against SSH and web logins.
- It is part of the security baseline I apply to every server, next to keys-only SSH, ufw and automatic updates.
- It also makes logs readable again: fewer junk attempts means real problems stand out.
How it works
01The idea: a bouncer who reads the guest book
Imagine a club bouncer who checks the logbook every few seconds. If the same person tried the wrong password five times in ten minutes, they are refused entry for an hour. That is fail2ban. Technically it is a Python daemon that follows log files or the systemd journal, matches each new line against regular expressions, counts matches per IP address, and when a threshold is crossed runs an action, usually adding a firewall rule (nftables, iptables or ufw) that drops the attacker's packets. After the ban time it removes the rule. It is called log-based intrusion prevention: it does not inspect traffic itself, it reacts to what your services already log. That makes it lightweight and service-agnostic: anything that logs failures with an IP can be protected.
02Jails, filters and actions
fail2ban is built from three pieces. A filter (filter.d/*.conf) is a set of regular expressions that recognise a failure in a log line and capture the host with the <HOST> tag. An action (action.d/*.conf) says what to do on ban and unban: firewall rules, a Cloudflare API call, an email. A jail ties them together: which log to read, which filter and action to use, and the thresholds. The thresholds are three numbers. maxretry is how many failures trigger a ban. findtime is the window in which those failures must happen. bantime is how long the ban lasts. With bantime.increment, repeat offenders get longer bans each time. The recidive jail goes further: it watches fail2ban's own log and bans IPs that keep getting banned, for days. Never edit jail.conf, which updates overwrite; put your settings in jail.local or jail.d/*.local.
- maxretry = 5, findtime = 10m, bantime = 1h is a sensible starting point.
- ignoreip keeps your own office or VPN addresses from ever being banned.
- On Ubuntu 24.04 and newer the sshd jail reads the systemd journal (backend = systemd), not /var/log/auth.log.
03A practical jail.local for SSH and nginx
This configuration protects SSH and the common nginx cases using filters that ship with fail2ban. nginx-http-auth catches failed basic-auth logins, nginx-botsearch catches bots requesting well-known exploit paths that return 404, and nginx-limit-req bans clients that repeatedly hit an nginx rate limit (it only works if you configured limit_req). Recidive handles persistent attackers. On Ubuntu, install with apt install fail2ban; the package already enables the sshd jail through jail.d/defaults-debian.conf with the nftables action. After editing, run fail2ban-client reload and check the status. If you use ufw and prefer to see bans in ufw status, set banaction = ufw instead.
# /etc/fail2ban/jail.local
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
bantime.increment = true
ignoreip = 127.0.0.1/8 ::1 203.0.113.10
[sshd]
enabled = true
backend = systemd
maxretry = 3
[nginx-http-auth]
enabled = true
[nginx-botsearch]
enabled = true
[recidive]
enabled = true
bantime = 1w
findtime = 1d
04Writing your own filter
You can protect anything that logs failures. A common example: a Laravel or Next.js site that is constantly probed for WordPress login pages and leaked .env files. Those requests are never legitimate on a non-WordPress site, so a few of them are a strong signal. The filter below matches such requests in the nginx access log (default combined format, where the client IP is the first field). Always test a new filter with fail2ban-regex against a real log before enabling it; it shows how many lines matched and catches regex mistakes that would ban nobody, or everybody. If the site is behind Cloudflare or another proxy, configure nginx's real_ip module first, otherwise the log contains the proxy's IP and you would ban the proxy.
# /etc/fail2ban/filter.d/nginx-probe.conf
[Definition]
failregex = ^<HOST> -.*"(GET|POST|HEAD) /(wp-login\.php|xmlrpc\.php|\.env|\.git/)
ignoreregex =
# /etc/fail2ban/jail.d/nginx-probe.local
[nginx-probe]
enabled = true
port = http,https
logpath = /var/log/nginx/access.log
maxretry = 2
bantime = 1d
# Test before enabling
sudo fail2ban-regex /var/log/nginx/access.log /etc/fail2ban/filter.d/nginx-probe.conf
05Checking, unbanning and debugging
fail2ban-client is the control tool. status shows the active jails, status with a jail name shows current failures and banned IPs, and set jail unbanip removes a ban (for example when a colleague mistyped their password too often). The fail2ban log, /var/log/fail2ban.log, records every ban and unban with the jail name, which is the first place to look when someone says they are locked out. If a jail never bans anything, check three things: the jail is actually enabled (status lists it), it reads the right source (log path or systemd backend), and the filter really matches the lines (fail2ban-regex). If it bans but traffic still arrives, the action may not match your firewall, or the traffic comes through a proxy whose IP is not the attacker's.
sudo fail2ban-client status # list active jails
sudo fail2ban-client status sshd # failures and banned IPs for one jail
sudo fail2ban-client banned # all banned IPs, per jail
sudo fail2ban-client set sshd unbanip 203.0.113.10
sudo fail2ban-client unban 203.0.113.10 # remove from every jail
sudo fail2ban-client reload # apply config changes
sudo tail -f /var/log/fail2ban.log
sudo nft list ruleset | grep -A3 f2b # see the firewall rules it created
06Limits, and fail2ban vs CrowdSec
fail2ban only sees attacks after they appear in a log, and it judges each IP separately. A distributed attack from thousands of addresses, each trying once or twice, never crosses the threshold. It does nothing against vulnerabilities in your app, and banning at the server firewall does not help when traffic arrives through a CDN proxy, because the connection comes from the CDN; there you need an action that blocks at the edge (fail2ban ships Cloudflare actions) or the CDN's own WAF rules. So treat it as noise reduction on top of real controls: SSH keys only, a default-deny firewall, rate limits in nginx, and a WAF such as Cloudflare in front. CrowdSec is the modern alternative: it parses logs similarly, but separates detection from blocking (bouncers for firewall, nginx or Cloudflare) and shares signals across its community, so an IP attacking others can be blocked before it reaches you. It is more powerful, with more moving parts and an online component.
Use it when
- On any internet-facing server with SSH open, even with keys-only login, to cut log noise and slow scanners.
- In front of login forms, basic-auth areas and admin panels that log failures with the client IP.
- To ban scanners probing paths that never exist on your site (.env, wp-login.php, .git).
- As a lightweight layer on small servers where a full WAF or SIEM would be overkill.
Skip it when
- As your main defence against distributed attacks or DDoS; use a CDN/WAF and rate limiting.
- Behind a proxy or load balancer without real-IP configuration; you would ban the proxy itself.
- When SSH is reachable only through a VPN or private network; there is little left to protect.
Alternatives
- CrowdSec
Log-based detection with separate bouncers and shared community blocklists; more features, more components.
- sshguard
Smaller, focused daemon for SSH and a few services; less configurable than fail2ban.
- Cloudflare WAF and rate limiting
Blocks attacks at the edge before they reach the server; covers HTTP only, not SSH.
- nginx limit_req / limit_conn
Built-in rate limiting per IP; prevents abuse in the moment instead of banning after the fact.
Best practices
- Put all settings in jail.local or jail.d/*.local; never edit jail.conf.
- Add your own static IPs to ignoreip before enabling strict jails.
- Test every custom filter with fail2ban-regex against real logs.
- Enable bantime.increment and the recidive jail for persistent attackers.
- Configure real client IPs when behind Cloudflare or a load balancer, and block at the edge there.
- Keep keys-only SSH and a default-deny firewall; fail2ban complements them, it does not replace them.
Common mistakes
- Locking yourself out by testing wrong passwords from your only connection with no ignoreip entry.
- Using a log path that does not exist (auth.log on journal-only systems), so the jail silently does nothing.
- Banning the CDN or load balancer IP because nginx logs the proxy address.
- Believing fail2ban stops distributed attacks or protects against application vulnerabilities.
Deep dive
07Inside the engine: backends, dates and the ban database
Each jail has a backend that delivers new log lines. systemd reads the journal directly and filters by unit, which is what modern Ubuntu needs for SSH. For plain files, auto tries pyinotify and falls back to polling. Every line must contain a timestamp that fail2ban recognises; it uses the time from the log, not the time it read the line, to decide whether a failure falls inside findtime. A wrong datepattern or a timezone mismatch makes failures look old, and the jail silently never bans. Bans are persisted in an SQLite database, /var/lib/fail2ban/fail2ban.sqlite3, so they are restored after a restart or reboot. dbpurgeage, one day by default, decides how long ban history is kept. That matters for recidive and incremental bans: if your recidive findtime is a week but history is purged after a day, repeat offenders are never recognised. Raise dbpurgeage in fail2ban.local to exceed your longest findtime.
# /etc/fail2ban/fail2ban.local
[Definition]
dbpurgeage = 8d # must be longer than recidive findtime
# Check which date format and lines a filter matches
sudo fail2ban-regex systemd-journal sshd
sudo fail2ban-regex --print-all-missed /var/log/nginx/access.log nginx-probe
08Incremental bans: punishing repeat offenders
A flat one-hour ban teaches a bot to come back in sixty-one minutes. With bantime.increment, fail2ban looks up how often the address was banned before and multiplies the ban. The default formula doubles it each time (ban time times two to the power of the ban count), so 1h becomes 2h, 4h, 8h and so on; bantime.multipliers lets you give an explicit sequence instead. bantime.maxtime caps the growth so a mistake does not lock a real user out for months, and bantime.rndtime adds a random extra so bots cannot time the unban precisely. bantime.overalljails = true counts previous bans from every jail, not only the current one: an address that probed nginx yesterday gets a longer SSH ban today. Together with persistent history, this often makes the separate recidive jail unnecessary, although keeping both is harmless.
# /etc/fail2ban/jail.local
[DEFAULT]
bantime = 1h
bantime.increment = true
bantime.factor = 1
bantime.maxtime = 1w
bantime.rndtime = 10m
bantime.overalljails = true
# alternative to the formula:
# bantime.multipliers = 1 2 4 8 16 32 64
09Actions at scale: nftables sets, IPv6 and the edge
Older iptables actions added one rule per banned address, and every packet walked through that list, which gets slow with thousands of bans. The nftables actions used by Debian and Ubuntu put addresses into a set inside an inet f2b-table, which is a hash lookup regardless of size and covers IPv4 and IPv6 in one table. The allports variant blocks every port, useful for recidive, while multiport blocks only the jail's ports. IPv6 needs thought: fail2ban bans single addresses, but one attacker may control a whole /64 and simply rotate. That is another reason to keep rate limits and keys-only SSH as the real controls. For HTTP behind Cloudflare, the cloudflare-token action creates a zone access rule through the Cloudflare API with a scoped token, so the block happens at the edge where the traffic actually arrives. Combine it with the local action so SSH and direct-to-origin hits are still blocked on the server.
# See the nftables sets fail2ban maintains
sudo nft list table inet f2b-table
# jail.d/nginx-probe.local: block locally and at Cloudflare
[nginx-probe]
action = %(action_)s
cloudflare-token[cfzone="your_zone_id", cftoken="your_scoped_token"]
10Writing filters that cannot be abused
A filter turns untrusted text into firewall rules, so it is a security boundary. Logs often contain attacker-controlled fields: a username, a URL, a User-Agent. If a regex is not anchored, an attacker can put a victim's IP address inside such a field and make you ban that victim, for example your payment provider or your monitoring service. This log injection risk is why shipped filters anchor on the fixed structure of the line and capture <HOST> only where the service itself writes the client address. Rules for custom filters: anchor with ^ and match the exact log format, capture the host from the field your server controls, keep the pattern specific to one failure meaning, and avoid nested greedy wildcards that are slow on long lines. Use ignoreregex for known-good traffic such as health checks. Test with fail2ban-regex against a week of real logs and look at both matched and missed lines before enabling.
Real-world scenarios
A colleague is locked out before a demo
Problem
A developer's new deploy script kept retrying SSH with a wrong user name from the office connection. Now their connection times out, and the demo starts in ten minutes.
How to solve it
From another session, confirm the cause with sudo fail2ban-client banned and grep their address in /var/log/fail2ban.log to see which jail banned it. Remove the ban with sudo fail2ban-client set sshd unbanip 203.0.113.10, or unban to clear it from every jail. If incremental bans are on, the history still counts, so for a known static office or VPN address add it to ignoreip in jail.local and reload. Then fix the root cause: correct the user in the script and stop it retrying blindly. If the team works from changing home IPs, a VPN with a fixed exit address is cleaner than widening ignoreip.
The sshd jail shows zero failures on a new server
Problem
The auth log is full of 'Invalid user' attempts in journalctl, but fail2ban-client status sshd reports zero failed and zero banned after a day.
How to solve it
The jail is reading the wrong source. On Ubuntu 24.04 and newer, SSH logs go to the journal and /var/log/auth.log may be missing or incomplete, so a jail with a file logpath never sees anything. Set backend = systemd for sshd in jail.local, reload with fail2ban-client reload and confirm with fail2ban-client status sshd that the journal matches appear. Test the filter directly with fail2ban-regex systemd-journal sshd. Also check that the sshd filter mode matches your log verbosity, and that the server's clock and timezone are correct, because failures timestamped outside findtime are ignored.
xmlrpc.php floods a site behind Cloudflare
Problem
A WordPress site behind Cloudflare is hammered with POST requests to xmlrpc.php. fail2ban reports bans, yet the requests keep arriving and PHP-FPM is saturated.
How to solve it
The bans hit nobody useful. Either nginx logs Cloudflare addresses, so you are banning Cloudflare, or the real IP is logged but traffic still arrives from Cloudflare's network. First configure the realip module with Cloudflare ranges and CF-Connecting-IP. Then add the cloudflare-token action to the jail so offenders are blocked at the edge. Better still for this case: if xmlrpc.php is not needed, deny it in nginx with location = /xmlrpc.php { deny all; } and add a Cloudflare WAF rule blocking that path, so requests never reach PHP. fail2ban stays for whatever slips through.
Cheat sheet
- Validate the configuration
sudo fail2ban-client -t- Dump the effective configuration
sudo fail2ban-client -d | less- Show a jail's current settings
sudo fail2ban-client get sshd bantime && sudo fail2ban-client get sshd maxretry- Ban an address manually
sudo fail2ban-client set sshd banip 203.0.113.10- Test a filter against the journal
sudo fail2ban-regex systemd-journal sshd- Show lines a filter missed
sudo fail2ban-regex --print-all-missed /var/log/nginx/access.log nginx-probe- Inspect the nftables sets
sudo nft list table inet f2b-table- Keep ban history for a week
[Definition] dbpurgeage = 8d # in fail2ban.local- Count bans per jail today
sudo grep "$(date +%F)" /var/log/fail2ban.log | grep -c ' Ban '
In my real projects
- fail2ban and ufw run on the production servers I manage.
- My baseline pairs fail2ban with unattended-upgrades, keys-only SSH and a firewall that only opens 22, 80 and 443.
- On sites behind Cloudflare, the edge handles DNS and basic DDoS protection while fail2ban covers what reaches the server.
Questions & answers
Q1What is fail2ban and how does it work?
fail2ban is a log-based intrusion prevention daemon. It follows log files or the systemd journal, applies filters, which are regular expressions that detect failures and extract the client IP, and counts failures per IP. When an IP reaches maxretry failures within findtime, the jail runs its action, typically adding a firewall rule through nftables, iptables or ufw, for the duration of bantime. Then it unbans automatically. Jails combine a log source, a filter, an action and thresholds, so the same mechanism protects SSH, nginx basic auth, mail servers or your own application, as long as it logs failures with the source address.
Q2If SSH already uses keys only, why run fail2ban at all?
With keys only, password brute force cannot succeed, so fail2ban is no longer protecting the login itself. It still adds value: it reduces log noise so real events stand out, saves a little CPU and bandwidth spent on endless handshakes, slows down scanners looking for weak services or misconfigurations, and protects other services such as web logins and admin areas. It also gives a safety net if someone temporarily re-enables passwords. But I would never rely on it instead of keys-only SSH, a default-deny firewall and updates. It is a cheap extra layer, not the foundation.
Q3What are bantime, findtime and maxretry?
They are the thresholds of a jail. maxretry is the number of failures that triggers a ban. findtime is the sliding window in which those failures must occur; five failures over a week would not count if findtime is ten minutes. bantime is how long the IP stays blocked; a negative value means permanent. Typical starting values are five failures in ten minutes for a one-hour ban, stricter for SSH. With bantime.increment enabled, each repeat ban lasts longer, and the recidive jail bans IPs that are banned repeatedly for days. Too strict values lock out real users, too loose values let slow attackers through.
Q4Our site is behind Cloudflare. Does fail2ban still help?
Only with extra configuration. With proxying enabled, every HTTP connection reaches your server from Cloudflare's addresses, so nginx logs Cloudflare IPs unless you configure the real_ip module with Cloudflare's ranges and the CF-Connecting-IP header. Even then, a server firewall ban blocks the attacker's IP, but their requests still arrive from Cloudflare, so the ban has no effect. You need an action that creates a block rule in Cloudflare through its API, or better, use Cloudflare's WAF and rate limiting directly. For SSH, which does not go through Cloudflare, fail2ban works normally. I also restrict ports 80 and 443 to Cloudflare's IP ranges.
Q5How would you compare fail2ban and CrowdSec?
Both read logs and ban misbehaving IPs. fail2ban is a single, mature daemon: simple, offline, easy to reason about, configured with regex filters and actions. CrowdSec separates detection, done by its agent with parsers and scenarios, from remediation, done by bouncers for the firewall, nginx, Cloudflare and others, so one detection engine can feed several enforcement points. Its main extra is the shared community blocklist: IPs reported by other users can be blocked before they touch your server. The cost is more components, an online console and more to learn. For one or two small servers fail2ban is enough; for a fleet, CrowdSec scales better.
Senior-level questions
S1You run fifteen servers. How do you handle brute-force protection across the fleet?
Per-server fail2ban works, but each server learns alone: an address banned on one is fresh on the other fourteen. Options, in order of effort: deploy identical fail2ban configuration through Ansible so at least behaviour is consistent; add an action that pushes bans to a shared blocklist, such as the cloud provider firewall or Cloudflare, so one detection protects everyone; or move to CrowdSec, whose local API lets several agents share decisions and whose bouncers enforce them on firewalls, nginx and the CDN. The bigger win is architectural: put SSH behind a VPN or bastion so only one host is exposed, and let the edge handle HTTP. Then brute-force protection is a concern of two components, not fifteen.
S2What are the risks of running fail2ban itself?
It runs as root and converts log content into firewall rules, so it widens your attack surface slightly. The main risk is log injection: a poorly anchored custom filter lets an attacker write a victim's address into a username or URL and get it banned, which can block your payment provider, monitoring or office. Second, availability: overly aggressive jails ban real customers behind shared carrier-grade NAT, where thousands of users share one IPv4 address. Third, false confidence: a jail that silently matches nothing looks like protection. I mitigate with anchored filters tested by fail2ban-regex, ignoreip for critical partners, moderate thresholds on public web jails, and alerts when a jail that normally bans goes quiet.
S3Rate limiting in nginx or banning with fail2ban: which do you use for a login endpoint?
Both, because they do different jobs. limit_req acts in real time on every request: it caps how fast any single address can try passwords, protects the application from overload and returns 429 instantly, without waiting for logs. It forgets quickly, so a slow attacker can keep going indefinitely. fail2ban acts after the fact on accumulated evidence across minutes or days and removes the attacker entirely at the firewall, saving TLS handshakes and application work. The application should add its own controls too: per-account throttling, since attackers rotate IPs but target the same accounts, plus multi-factor authentication. The order is defence in depth: edge WAF, nginx rate limit, app throttling, then fail2ban banning persistent sources.
Glossary
- Jail
- A fail2ban unit combining a log source, a filter, an action and thresholds for one service.
- Filter
- A set of regular expressions that recognise failure lines in a log and extract the offending address.
- Action
- What fail2ban runs on ban and unban, such as adding the address to an nftables set or calling an API.
- failregex
- The regular expression in a filter that marks a log line as a failure; it must capture <HOST>.
- <HOST>
- fail2ban's placeholder inside failregex that matches and captures an IP address or hostname.
- Backend
- How a jail receives log lines: from the systemd journal, inotify file watching or periodic polling.
- Recidive
- A jail that watches fail2ban's own log and bans addresses that keep getting banned, for much longer.
- ignoreip
- Addresses or networks that are never banned, such as your office, VPN or monitoring service.
- Log injection
- Tricking a log parser by placing crafted text, such as a victim's IP, into fields the attacker controls.
Keep learning
Have a product in mind?Let's build it.
Tell me about your idea, your product or your team. I usually reply within one working day.