In short. fail2ban watches service logs, counts failed login attempts per IP address and temporarily adds that address to your firewall rules. Install it with apt, configure it in jail.local rather than jail.conf, and tune four parameters: maxretry, findtime, bantime and ignoreip. On modern Ubuntu systems sshd logs live in journald, so the jail needs backend systemd or it silently does nothing.
What fail2ban does and what it does not do
fail2ban is a daemon that tails plain-text logs (or the systemd journal), matches lines against a filter regular expression, and when a single IP produces maxretry matches inside the findtime window, it triggers an action — normally inserting a DROP or REJECT rule into the firewall for bantime seconds. When bantime expires, the rule is removed. That is the whole mechanism.
Those mechanics imply hard limits that you should understand before rolling it out.
- It is not a firewall. fail2ban never closes ports and never defines an access policy. It only injects temporary rules into a firewall you already run — iptables, nftables, ufw or firewalld. If your database port is exposed to the internet, fail2ban will not save you; you need a packet filter and a proper review of open ports.
- It is not protection against distributed brute force. A modern botnet makes two or three attempts from each of tens of thousands of addresses. The maxretry threshold is never reached, and when it is, you ban one address out of a huge pool. The real countermeasure is disabling password authentication and applying rate limiting at the application layer.
- It is not a WAF. fail2ban does not parse request bodies, does not understand SQL injection and never sees the payload. It only sees what the service itself wrote into a log.
- It is reactive by design. The first maxretry attempts always go through. If the password is guessed on attempt three while maxretry is five, the ban is pointless.
- It is useless without logs. If a service does not record failed logins, or records them in a format your filter does not match, the jail sits with a zero counter and creates a false sense of safety.
fail2ban removes background noise and reduces load on sshd. It does not replace sound authentication. Keys instead of passwords,
PasswordAuthentication no, and closing internal services to the outside world buy you far more than any jail tuning.

Installing fail2ban on Debian and Ubuntu
The package ships in the main repositories of both distributions, so no extra repository is needed.
sudo apt update
sudo apt install fail2ban
# version and daemon state
fail2ban-client version
systemctl status fail2ban
# what is already enabled out of the box
sudo fail2ban-client status
On Debian and Ubuntu the package drops /etc/fail2ban/jail.d/defaults-debian.conf, which enables the sshd jail. In other words, minimal SSH protection usually starts working right after installation — but verify it by hand instead of trusting the default, for reasons explained in the backend section below.
If your logs live in the systemd journal, install the Python journald bindings as well; without them the systemd backend may fail to start:
sudo apt install python3-systemd
sudo systemctl restart fail2ban
sudo journalctl -u fail2ban -n 50 --no-pager
File layout after installation:
/etc/fail2ban/fail2ban.conf— daemon settings: log level, socket, ban database,dbpurgeage./etc/fail2ban/jail.conf— the reference description of every jail. Do not edit it./etc/fail2ban/jail.d/*.conf— configuration fragments, loaded after jail.conf./etc/fail2ban/filter.d/*.conf— filters, meaning regular expressions./etc/fail2ban/action.d/*.conf— actions: how to ban (iptables, nftables, e-mail, external API)./var/lib/fail2ban/fail2ban.sqlite3— the ban database, which is why bans survive a daemon restart./var/log/fail2ban.log— its own log; the recidive jail reads exactly this file.
jail.local, jail.d and the parameters that matter
Why you must not edit jail.conf
jail.conf belongs to the package. On any upgrade the package manager sees a modified conf file and will either overwrite it, prompt you about a configuration conflict, or leave a .dpkg-dist file next to it. In every scenario you lose either your changes or the new upstream defaults.
fail2ban reads configuration in layers: jail.conf first, then jail.local, then everything inside jail.d/ in alphabetical order. A later layer overrides an earlier one per parameter, not per section, so jail.local only needs to list what you actually change.
sudo tee /etc/fail2ban/jail.local > /dev/null <<'EOF'
[DEFAULT]
# trusted networks: LAN, office, VPN, monitoring probes
ignoreip = 127.0.0.1/8 ::1 203.0.113.0/24
bantime = 1h
findtime = 10m
maxretry = 5
# escalating bans: repeat offenders stay out longer
bantime.increment = true
bantime.factor = 2
bantime.maxtime = 7d
banaction = nftables-multiport
[sshd]
enabled = true
mode = aggressive
maxretry = 4
bantime = 1d
EOF
sudo fail2ban-client -t # syntax check without applying
sudo systemctl reload fail2ban
The [DEFAULT] section sets values for every jail; a jail section overrides them. Separate files in jail.d/ are convenient when configuration is deployed by Ansible or Puppet: one service, one file, no merge conflicts.
Choosing bantime, findtime, maxretry and ignoreip
The logic is simple: if one IP produces maxretry filter matches within findtime, it is blocked for bantime.
- maxretry. For key-based SSH, 3 to 4 is right — a legitimate user with a valid key produces no failures at all. For a web login page, where people genuinely forget passwords, use 5 to 10 or you will flood your support queue. Remember that a single browser attempt can emit several log lines.
- findtime. The observation window. Too short (one or two minutes) misses slow "one attempt per minute" scans. A sane range is 10 to 60 minutes. The wider the window, the more memory is used and the higher the chance of catching a human who keeps mistyping.
- bantime. One hour is a reasonable start. A permanent ban (
bantime = -1) is tempting but inflates the firewall rule set to tens of thousands of entries and eventually traps a legitimate address you will forget to release. The correct answer is not permanent bans butbantime.increment: each subsequent ban of the same address lasts longer. - ignoreip. Addresses and CIDR ranges that are never banned. Your workstation network, VPN, internal probes and load balancers belong here. Values are space-separated; CIDR is supported and recent versions accept DNS names. The
ignoreself = truedefault additionally protects the server's own addresses.
Tuning rule: start with permissive thresholds and watch the statistics for a week. An aggressive
maxretry = 2on a public site is not security, it is an incident generator titled "customers cannot log in".
Backend: systemd, auto and the journald trap
The backend parameter defines where a jail takes events from: a text file or the systemd journal.
auto— fail2ban picks a mechanism for watching a file: pyinotify when available, otherwise periodic polling. The key word is file.autodoes not mean "it will find the logs wherever they are".polling— forced timer-based polling. Slower and heavier, but works where inotify is unavailable (some containers, network filesystems).systemd— reads journald directly through its API. In this modelogpathis ignored and record selection is set by thejournalmatchdirective.
Here is the single most common reason behind "I installed fail2ban and it bans nothing". Historically sshd logged to /var/log/auth.log through rsyslog, and the default jail is written for exactly that. On recent Ubuntu installations rsyslog may be absent: logging is fully delegated to systemd-journald and /var/log/auth.log is never created. The jail then either fails with an unreachable-log error or, if an empty leftover file exists after an upgrade, quietly runs with a zero counter.
# does a text auth log exist at all
ls -l /var/log/auth.log 2>/dev/null || echo "no auth.log — logs are in journald"
# does journald actually see login failures
journalctl -u ssh.service --since "-1h" | grep -i "failed password" | tail
# what fail2ban itself reports at startup
sudo journalctl -u fail2ban --since "-10m" --no-pager | grep -iE "error|warn|found"
If the logs are in the journal, switch the jail to the systemd backend:
sudo tee /etc/fail2ban/jail.d/sshd-systemd.local > /dev/null <<'EOF'
[sshd]
enabled = true
backend = systemd
journalmatch = _SYSTEMD_UNIT=ssh.service + _COMM=sshd
maxretry = 4
bantime = 1d
EOF
sudo fail2ban-client -t
sudo systemctl restart fail2ban
sudo fail2ban-client status sshd
Watch the unit name: on Debian and Ubuntu it is ssh.service, while many RPM-based distributions use sshd.service. A wrong journalmatch does not raise an error — the jail simply sees no lines. Verify with fail2ban-regex, described below, rather than by the absence of errors.
The signature of a silent jail:
fail2ban-client status sshdreports "Total failed" as zero whilejournalctlshows dozens of "Failed password" entries in the last hour. That is not "the attacks stopped", that is the wrong log source.

The sshd jail and other common jails
The sshd jail is the baseline and often all you need. Its filter supports several mode values that change the regex set: normal catches failed passwords and authentication errors, ddos catches dropped connections and empty sessions without a login attempt, and aggressive combines both, including invalid users and unsupported authentication methods. On a public server aggressive is usually justified; on a host where custom scripts frequently tear down SSH sessions it is risky.
| Jail | What it catches | Log source | maxretry | bantime |
|---|---|---|---|---|
sshd | password guessing, logins with non-existent users, abnormal disconnects | /var/log/auth.log or journald (ssh.service) | 3–5 | 1 hour – 1 day |
nginx-http-auth | credential guessing against HTTP Basic Auth | /var/log/nginx/error.log | 3–5 | 1–6 hours |
nginx-botsearch | path scanning: admin panels, .env, backups, foreign CMS paths | error.log, optionally access.log | 5–10 | 1 day |
nginx-limit-req | limit_req triggers, meaning request-rate violations | /var/log/nginx/error.log | 10 | 10 minutes – 1 hour |
postfix, postfix-sasl | SMTP password guessing, relay attempts | /var/log/mail.log or journald | 3–5 | 1 hour – 1 day |
recidive | addresses already banned several times by other jails | /var/log/fail2ban.log | 3–5 | 1 week and longer |
The recidive jail deserves special attention: it reads fail2ban's own log rather than a service log and bans addresses that were banned several times within a day. It is a cheap way to push persistent bots into a long block without lowering thresholds for ordinary users.
[recidive]
enabled = true
logpath = /var/log/fail2ban.log
banaction = %(banaction_allports)s
findtime = 1d
maxretry = 4
bantime = 4w
The banaction_allports action blocks the address on every port instead of a specific one, which is a sensible policy for repeat offenders.
fail2ban and nginx: ready-made jails plus your own filter
There is a subtlety with web servers: most stock nginx filters read error.log, because that is where nginx records Basic Auth rejections and limit_req triggers. Plain response codes 401, 403 and 429 live in access.log, and catching them requires a custom filter.
[nginx-http-auth]
enabled = true
port = http,https
logpath = /var/log/nginx/error.log
maxretry = 4
bantime = 6h
[nginx-botsearch]
enabled = true
port = http,https
logpath = /var/log/nginx/error.log
maxretry = 6
bantime = 1d
[nginx-limit-req]
enabled = true
port = http,https
logpath = /var/log/nginx/error.log
findtime = 5m
maxretry = 20
bantime = 1h
The nginx-limit-req jail is meaningful only together with limit_req_zone and limit_req directives in the nginx configuration; without them error.log has nothing to offer. The pairing — nginx throttles, fail2ban bans the most persistent — is a solid two-stage design, covered further in the articles on rate limiting strategies and DDoS protection methods.
Writing a filter in filter.d and testing it with fail2ban-regex
Suppose you want to ban addresses that generate a burst of 401 and 429 responses on an API. Here is a filter for the standard combined access.log format.
sudo tee /etc/fail2ban/filter.d/nginx-4xx-api.conf > /dev/null <<'EOF'
[Definition]
failregex = ^<HOST> \S+ \S+ \[[^]]+\] "(GET|POST|PUT|PATCH|DELETE) /api/[^"]*" (401|403|429)
ignoreregex =
datepattern = ^[^\[]*\[({DATE})
EOF
Rules for writing a filter:
<HOST>is a mandatory marker: fail2ban extracts the IP address from exactly this group. Use one per expression.- The regex must be anchored (
^) and as narrow as possible. A loose pattern such as.*401.*will match a line where 401 is part of the response size or the User-Agent string and will ban a real customer. ignoreregexholds exceptions evaluated after failregex. Useful to exclude service paths such as/api/health.- All filters live in
filter.d/, and a jail references them by file name without the extension.
Now the important part — verification. fail2ban-regex runs a filter against a real log and reports how many lines matched and how the timestamp was parsed.
# filter against a file
fail2ban-regex /var/log/nginx/access.log /etc/fail2ban/filter.d/nginx-4xx-api.conf
# stock sshd filter against the systemd journal
fail2ban-regex systemd-journal /etc/fail2ban/filter.d/sshd.conf
# test a single line for quick regex debugging
fail2ban-regex '198.51.100.7 - - [12/Mar/2026:10:00:01 +0300] "POST /api/login HTTP/1.1" 401 12' \
/etc/fail2ban/filter.d/nginx-4xx-api.conf
# verbose breakdown: which lines did not match
fail2ban-regex --print-all-missed /var/log/nginx/access.log \
/etc/fail2ban/filter.d/nginx-4xx-api.conf
Two numbers matter in the output: Lines: ... matched and the missed-lines counter. Zero matches while attacks are clearly present means the regex or the date pattern is wrong. A frequent separate problem is datepattern: if fail2ban cannot parse the timestamp, it either treats the event as stale or ignores it. The fail2ban-regex output shows this in the "ignored by ... date" counter.
Wire the filter into a jail:
[nginx-4xx-api]
enabled = true
filter = nginx-4xx-api
port = http,https
logpath = /var/log/nginx/access.log
findtime = 5m
maxretry = 25
bantime = 2h

fail2ban-client: status, unbanning and not locking yourself out
All day-to-day control goes through fail2ban-client, which talks to the daemon over a unix socket.
# list active jails
sudo fail2ban-client status
# details of one jail: counters and the banned list
sudo fail2ban-client status sshd
# release a specific address
sudo fail2ban-client set sshd unbanip 198.51.100.7
# release it across every jail
sudo fail2ban-client unban 198.51.100.7
sudo fail2ban-client unban --all
# ban manually, for example from an external feed
sudo fail2ban-client set sshd banip 198.51.100.7
# add an address to the ignore list on the fly, no restart
sudo fail2ban-client set sshd addignoreip 203.0.113.10
# reload configuration without losing active bans
sudo fail2ban-client reload
sudo fail2ban-client reload sshd
Now the most popular self-inflicted outage: banning the administrator. The scenario is always the same — you edit a jail, make a mistake in the filter, it starts matching ordinary successful connections, and a minute later your own IP is dropped. The existing SSH session usually survives, because the connection is already established, but no new session can be opened.
- Always put your address into
ignoreipbefore enabling new jails. If your address is dynamic, add the provider range or your VPN address. - Keep a second SSH session open while experimenting. It is free insurance: even if new authentication is blocked, the old session can run the unban command.
- Have an out-of-band access path: a hosting panel console, KVM or serial console. Cloud web consoles bypass the network path and the firewall entirely.
- Test every new filter with fail2ban-regex before enabling the jail, not after.
If access is already lost and only a console is left, clear the bans manually:
# find where the rule actually lives
sudo nft list ruleset | grep -A5 f2b
sudo iptables -S | grep f2b
# emergency: release everything
sudo fail2ban-client unban --all
# last resort: stop the daemon and let it flush its chains
sudo systemctl stop fail2ban
Pitfalls: firewalls, reverse proxies, log rotation and IPv6
iptables, nftables and ufw
fail2ban does not replace the firewall, it writes into it. The banaction parameter decides how. The main options are iptables-multiport (classic), nftables-multiport (for systems whose native firewall is nftables), ufw and firewallcmd-ipset. Mixing rule sets blindly is a bad idea: if the system runs nftables while fail2ban writes through the iptables compatibility layer, rules land in separate tables and evaluation order may not be what you expect.
ufw adds its own twist: it creates dedicated chains, and fail2ban rules must be inserted before ufw's allow rules or the ban has no effect. The stock ufw action handles that; homemade combinations often do not. After configuring, always inspect the actual rule state:
sudo nft list table inet f2b-table 2>/dev/null
sudo iptables -L -n --line-numbers | grep -i f2b
sudo ufw status numbered
General principles for hardening the packet filter and the services behind it are covered in the guide to web server hardening.
Reverse proxies and CDNs: the wrong address gets banned
If a CDN, a load balancer or another nginx sits in front of the server, backend logs contain the proxy IP rather than the client IP. fail2ban will dutifully ban it, cutting off all inbound traffic at once.
The fix belongs to the web server, not to fail2ban: $remote_addr must be restored from the header the proxy sets. The realip module rewrites the client address for both access.log and error.log entries.
http {
# networks allowed to set the header
set_real_ip_from 192.0.2.0/24; # your load balancer
# for a CDN, list the provider's published ranges
real_ip_header X-Forwarded-For;
real_ip_recursive on;
}
Trust
X-Forwarded-Foronly from networks explicitly listed inset_real_ip_from. If you trust everyone, any client can forge the header and make fail2ban ban an arbitrary IP — from your own address to search engine crawlers. The header mechanics and trust model are covered separately: the X-Forwarded-For header.
A second caveat: even with correct realip, a firewall ban on the origin server is useless when traffic always arrives from the CDN. The block has to happen on the CDN side through its API. fail2ban ships actions for external APIs, but their reliability depends on that API's rate limits and availability.
Log rotation and reset counters
logrotate renames access.log to access.log.1 and creates an empty file. fail2ban reopens the file when the inode changes and keeps working, but historical lines move into the archive. Practical consequences:
- If fail2ban restarts (package upgrade, service reload, reboot), it starts reading the current file. Right after rotation that file is empty, recent context is lost, and an attacker gets a fresh maxretry budget.
- Active bans are not lost: they live in the sqlite database, and record retention is governed by
dbpurgeageinfail2ban.conf. - Do not set
findtimelonger than the rotation period. A one-day window with daily rotation is half blind. - With journald the problem disappears: the journal is read by cursor and does not rotate as a plain file.
IPv6 limitations
Current fail2ban versions can ban IPv6, with caveats. First, you need a banaction that supports both protocol versions: nftables works with an inet table and covers both stacks, while classic iptables requires parallel ip6tables rules. Second, an IPv6 client usually holds an entire /64 or larger prefix, so banning one address achieves nothing — the attacker moves to the next address in the same prefix. Some actions let you set a ban prefix length, but enabling aggressive /64 blocking on a public service is a deliberate trade-off: many unrelated users can sit behind one prefix.
The simplest way to confirm that a jail sees IPv6 at all is the actual banned list in fail2ban-client status and the rules present in nftables.

How to verify your setup and server exposure
A fail2ban deployment is verified on two levels: inside the server with the commands above, and from the outside by what an attacker actually sees.
- Which ports are exposed. fail2ban protects only the services you put in jails; everything else must be closed by the firewall. An external scan shows the real picture: open port scanner. If a database, control panel or debug port answers from the internet, that matters far more than any jail tuning.
- Overall security scoring. Headers, TLS, version leaks and typical misconfigurations are collected into one report by the website security check. Run it after every web server configuration change.
- Spikes in errors and downtime. Overly aggressive jails show up as a rise in 4xx responses, dropped connections and partial unavailability. External uptime monitoring with alerts catches both that and the opposite case, where the server goes down under a brute-force wave.
- Reaction to triggers. Configure a notification action (the stock action.d entries that send e-mail) or ship events from
/var/log/fail2ban.loginto your log platform. A silent fail2ban is a fail2ban whose state you know nothing about.
If the checks reveal signs of an existing compromise, the response sequence is described separately: what to do when a website is hacked.
Frequently asked questions
Do I need fail2ban if SSH accepts keys only?
Strictly speaking, no: with PasswordAuthentication no a password cannot be guessed. The jail is still useful because it removes background bot noise, reduces the number of sshd processes and shrinks the logs. Use permissive thresholds — the goal here is hygiene, not defence.
Why does fail2ban report "Currently banned: 0" while attacks continue?
Three causes, in order of frequency: the jail reads the wrong source (a file instead of journald), the filter does not match the actual line format, or the attacking addresses fall into ignoreip. Diagnose with fail2ban-client status <jail> and fail2ban-regex against the real log.
Can I ban addresses permanently?
Technically yes, with bantime = -1. In practice bantime.increment = true capped by bantime.maxtime works better: the firewall rule set does not grow without bound, while repeat offenders still end up blocked for weeks. Keep permanent bans in a separate list you deliberately review.
Will fail2ban stop a DDoS attack?
No. In a volumetric attack the packets reach the server and consume bandwidth and CPU before anything is written to a log. fail2ban acts after the log entry appears, meaning after the request was already processed. DDoS requires upstream or CDN-level filtering plus nginx rate limits.
How do I move a fail2ban configuration to another server?
Copy only your own files: jail.local, the contents of jail.d/, custom filters from filter.d/ and custom actions from action.d/. Do not copy jail.conf or the ban database: the former ships with the package, the latter is specific to one machine.
How much load does fail2ban add?
On a typical VPS the overhead is negligible while the jail count is small and logs are moderate. Problems appear with a wide findtime on very busy access logs and with tens of thousands of firewall rules. Fix it by narrowing the window, moving to ipset or nftables sets, and using the recidive jail instead of permanent bans.
Deployment checklist
- Package installed, daemon enabled at boot,
fail2ban-client statusresponds. - All edits live in
jail.localandjail.d/;jail.confuntouched. - Your own address, VPN and monitoring probes are listed in
ignoreip. - You verified where sshd actually logs; with journald the jail uses
backend = systemdand a correctjournalmatch. fail2ban-client status sshdshows a non-zero "Total failed" counter on a public server.- Every custom filter was run through
fail2ban-regex, checking both matches and date parsing. banactionmatches the firewall actually in use (iptables, nftables or ufw), and the rules are visible in the firewall output.- Behind a reverse proxy or CDN, realip is configured with an explicit list of trusted networks.
bantime.incrementor therecidivejail is used instead of permanent bans.- An out-of-band access path exists in case you ban yourself.
- An external port scan and security check were run, and external uptime monitoring is in place.
- SSH password authentication is disabled — the primary control that makes fail2ban a secondary measure.