What it does and why
On any public SSH host, port 22 receives thousands of brute-force
attempts per day: dictionary guesses like root/password, admin/admin, etc. A strong password
plus keys protect you, but the logs fill up with noise and CPU is wasted on bcrypt checks.
fail2ban works like this:
/var/log/auth.log
↓ tail -F
fail2ban reads line by line
↓
applies a regex filter (filter.d/sshd.conf)
↓
if IP X has made N FAILs within findtime seconds
↓
→ add a REJECT rule for X in the firewall for bantime seconds
↓
when it expires, remove the rule
It applies to anything that writes logs: SSH, Apache/nginx (auth_basic), Postfix (smtp-auth), Dovecot, ProFTPD, Asterisk, named (DNS amplification).
Installation
# Debian/Ubuntu
sudo apt install fail2ban
# RHEL/Fedora needs EPEL
sudo dnf install epel-release
sudo dnf install fail2ban
The daemon is fail2ban (via systemd: fail2ban.service).
The CLI is fail2ban-client.
Config architecture
/etc/fail2ban/
├── jail.conf ← vendor, DO NOT TOUCH (overwritten on apt upgrade)
├── jail.local ← your override (create it yourself)
├── jail.d/ ← modular overrides (50-sshd.local and so on)
├── filter.d/ ← regex filters per service (sshd.conf, apache-auth.conf)
├── action.d/ ← actions (firewallcmd-ipset, iptables, ufw)
└── fail2ban.local ← global daemon settings
The standard path: copy jail.conf to jail.local and edit local.
fail2ban reads both, and local overrides vendor.
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
sudo nano /etc/fail2ban/jail.local
Minimal jail.local
[DEFAULT]
# Global settings for all jails
bantime = 1h # how long to ban (supports s, m, h, d, w)
findtime = 10m # window for counting attempts
maxretry = 5 # attempt threshold
backend = systemd # read the journal instead of files (important!)
ignoreip = 127.0.0.1/8 ::1 192.168.0.0/24 # whitelist
banaction = iptables-multiport # or firewallcmd-ipset, nftables-multiport
# SSH is almost always the first thing we enable
[sshd]
enabled = true
port = ssh
filter = sshd
logpath = %(sshd_log)s
maxretry = 3 # stricter than the global value for SSH
bantime = 24h # and ban for longer
Without enabled = true, a jail is not activated even if it is mentioned.
The options %(sshd_log)s and %(sshd_backend)s are variables from paths-*.conf;
they automatically substitute the correct path for your distribution.
bantime / findtime / maxretry
Semantics:
maxretryis how many failures are enough for a ban.findtimeis the window in seconds within which maxretry is counted.bantimeis how long to ban.
Example: findtime=600 maxretry=5 → 5 failures in 10 minutes triggers a ban.
Too strict (maxretry=2) and you ban yourself on a typo. Too
lenient (maxretry=20) and bots have time to guess. A reasonable SSH default is
maxretry=3, bantime=24h, findtime=10m.
bantime.increment: exponential ban
Modern fail2ban can grow bantime for repeat offenders:
[DEFAULT]
bantime = 1h
bantime.increment = true # enable
bantime.factor = 2 # multiplier
bantime.maxtime = 1w # ceiling
First ban 1h, second 2h, fourth 8h, ..., capped at a week. After this, bots give up.
Which backend to choose
autolets fail2ban decide for itself (often works poorly).pyinotifywatches a file via inotify (fast, for logs in a file).systemdreads journald directly. It is recommended for modern distributions where sshd writes to the journal rather than to auth.log.
If the backend is wrong, fail2ban will start but see nothing. The symptom:
fail2ban-client status sshd shows Total failed: 0 even though the journal has
a million FAILs.
Activating specific jails
By default (jail.conf), most jails have enabled = false. Enable
only the ones you need.
The most popular ones:
[sshd]
enabled = true
...
[apache-auth]
enabled = true
port = http,https
logpath = /var/log/apache2/error.log
[postfix]
enabled = true
port = smtp,ssmtp,submission
logpath = %(postfix_log)s
[recidive]
enabled = true # meta-jail: bans those already banned in other jails
bantime = 1w
findtime = 1d
maxretry = 5
recidive is a cross-jail repeat offender. Someone gets banned in sshd, then in
apache-auth, and recidive bans them for a week on top of the regular ban.
Managing with fail2ban-client
# overview of all active jails
sudo fail2ban-client status
▸Status
▸|- Number of jail: 2
▸`- Jail list: sshd, recidive
# details for a specific jail
sudo fail2ban-client status sshd
▸Status for the jail: sshd
▸|- Filter
▸| |- Currently failed: 3
▸| |- Total failed: 1247
▸| `- File list: /var/log/auth.log
▸`- Actions
▸|- Currently banned: 5
▸|- Total banned: 143
▸`- Banned IP list: 45.x.x.x 196.x.x.x ...
# manual ban / unban
sudo fail2ban-client set sshd banip 1.2.3.4
sudo fail2ban-client set sshd unbanip 1.2.3.4
# lift all bans in all jails
sudo fail2ban-client unban --all
# reread the config without a restart
sudo fail2ban-client reload
sudo fail2ban-client reload sshd # a single jail
How to write your own filter
Suppose you have your own API that logs [AUTH-FAIL] from 1.2.3.4 invalid token.
Create /etc/fail2ban/filter.d/myapi.conf:
[Definition]
failregex = ^.*\[AUTH-FAIL\] from <HOST> .*$
ignoreregex =
<HOST> is a special fail2ban token for substituting an IP/hostname.
Wire up the jail in jail.local:
[myapi]
enabled = true
filter = myapi
port = http,https
logpath = /var/log/myapi/access.log
maxretry = 3
Check the regex without a restart:
fail2ban-regex /var/log/myapi/access.log /etc/fail2ban/filter.d/myapi.conf
▸Lines: 1234 lines, 0 ignored, 17 matched, 1217 missed
If matched: 0, the regex does not work.
Debugging: why it does not ban
sudo systemctl status fail2ban
sudo journalctl -u fail2ban -f # stream the daemon's logs
sudo tail -F /var/log/fail2ban.log # its own log
# check that the jail sees its log:
sudo fail2ban-client status sshd
# Total failed should grow during attempts
# test the regex against a real file
sudo fail2ban-regex /var/log/auth.log /etc/fail2ban/filter.d/sshd.conf
Common reasons for "it does not work":
- Backend mismatch: sshd writes to the journal, but the jail is configured for a file.
Set
backend = systemd. - maxretry too high: you ban yourself during tests before the bots accumulate enough. Set it to 2 temporarily.
- ignoreip covers the attacker: check whether their subnet is included.
- the firewall did not apply the rule: on nftables systems with
iptables-legacythere can be a conflict. Usebanaction = nftables-multiport.
Comparison with alternatives
- CrowdSec is a modern replacement that shares blocklists across all installations (federated). It is more complex but more effective against botnets.
- sshguard is lightweight, only for SSH/SMTP/etc, written in C rather than Python.
- firewalld + ipset by hand means you write the parsing script yourself. Do not do this in production.