Fail2ban SSH Brute-Force Defense: Jail Config
End-to-end Fail2ban vs SSH brute force: systemd log backend, maxretry/findtime/bantime tuning in jail.local, mode=aggressive, ipset ban action, ignoreip to avoid self-lock, ban/unban checks, pairing with sshd password-off, and four FAQs.
Fail2ban vs SSH brute force is the baseline auto-ban on any public box: match failed auth in sshd logs, trip a threshold, ban the source via iptables/nftables. Copy-ready config below — jail.local tuning, systemd backend, ipset bans, whitelist against self-lock, status and unban, plus verify and FAQ.
Scope
- SSH port on the public net. `/var/log/auth.log` or journal full of `Failed password`.
- Password auth already off, keys only, but you still need to cut probes and service fingerprinting.
- Auto-ban by IP. No hand-maintained firewall blacklist.
- Same landing on cloud VMs, VPS, or bare metal, compatible with firewalld/nftables.
Prerequisites
- Distro: Ubuntu/Debian or CentOS/Rocky/AlmaLinux, systemd available.
- Fail2ban 0.11+ (1.0.x preferred). Ban backend iptables or nftables.
- sshd logging on. Failed auth readable via `journalctl -u ssh` (CentOS/Rocky) or `journalctl -u sshd` (Debian/Ubuntu).
- root, plus one working out-of-band login (console VNC or a second SSH session) so you cannot lock yourself out.
How it works
Three parts: filter regex-extracts “fail event” + source IP from logs; jail says which log, which filter, how many fails before how long a ban; action does the ban, default iptables DROP on a dedicated chain.
Three knobs: `findtime` is the watch window, `maxretry` is max fails in that window, `bantime` is ban length. Example `maxretry=3`, `findtime=600`: same IP, 3 failed auths in 10 minutes → banned. Negative `bantime` is permanent; in practice set days and review. Log source is `backend`: containers and minimal images often have no `/var/log/auth.log`. Switch to `systemd` and read journal, or you get the classic “installed, never bans.”
Procedure
Step 1: Install and confirm backend
# Debian/Ubuntu
apt update && apt install -y fail2ban ipset
# CentOS/Rocky/AlmaLinux
dnf install -y epel-release && dnf install -y fail2ban ipset
fail2ban-client --version
systemctl enable --now fail2ban
Confirm logs are readable:
journalctl -u ssh --no-pager | grep -Ei "Failed password|Invalid user" | tail -5
journalctl -u sshd --no-pager | grep -Ei "Failed password|Invalid user" | tail -5
One of the two hitting is enough. Hit on ssh → CentOS-family default jail. Hit on sshd → Debian-family default jail.
Step 2: Write jail.local
Do not edit `/etc/fail2ban/jail.conf` (upgrades overwrite). Put everything in `/etc/fail2ban/jail.local`:
[DEFAULT]
# Ban action: iptables multiport, firewalld-safe
banaction = iptables-multiport
backend = systemd
# Whitelist: localhost, RFC1918, jump host, scanner nodes
ignoreip = 127.0.0.1/8 ::1 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 203.0.113.7
findtime = 600
bantime = 86400
maxretry = 3
# Mail on ban/unban (optional)
# action = %(action_mwl)s
[sshd]
enabled = true
port = ssh
mode = aggressive
filter = sshd
logpath = %(sshd_log)s
maxretry = 3
findtime = 600
bantime = 604800
Knobs: mode = aggressive matches both `sshd` and `sshd-ddos`, so incomplete handshakes like `Connection closed by authenticating user` count. bantime = 604800 is 7 days; beats the default 10 minutes against persistent brute force. `ignoreip` must include jump hosts and dial-up egress or you lock yourself out.
If the distro has no `iptables-multiport`, list actions with `ls /etc/fail2ban/action.d/ | grep -Ei "iptables|nftables"`. On nftables use `banaction = nftables-multiport`.
Step 3: ipset for ban performance
Default inserts one rule per banned IP. At thousands of entries the iptables chain gets long. ipset matches in constant time:
ls /etc/fail2ban/action.d/ | grep ipset
If you see `iptables-ipset-proto4.conf` or `iptables-ipset-proto6.conf`, override the action in jail.local:
[sshd]
banaction = iptables-ipset-proto6
banaction_allports = iptables-ipset-proto6-allports
No ipset action files in the package → keep `iptables-multiport` and watch rule counts on inspection.
Step 4: Load config and confirm status
fail2ban-client -t
systemctl restart fail2ban
fail2ban-client status
fail2ban-client status sshd
Healthy output has `Filter`, `Currently failed`, `Total failed`, `Currently banned`, `Banned IP list`. Empty `Filter` → filter name vs distro mismatch (Debian is `sshd`; confirm `/etc/fail2ban/filter.d/sshd.conf` exists on some images).
Step 5: Verify ban and unban
# From a second host, fail password auth 3 times on purpose
ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no root@SERVER_IP
# Ban list and rule chain
fail2ban-client status sshd
iptables -L f2b-sshd -n --line-numbers
ipset list f2b-sshd 2>/dev/null | tail -20
# Unban one IP / unban all
fail2ban-client set sshd unbanip 203.0.113.10
fail2ban-client unban --all
# Manual ban (emergency)
fail2ban-client set sshd banip 203.0.113.10
Banned IP: SSH hangs until timeout. `fail2ban.log` shows `Ban 203.0.113.10` plus the triggering log line number. Those two lines are the proof the config works.
Step 6: Pair with sshd hardening and monitoring
Fail2ban only slows brute force. Shrink the login surface. `/etc/ssh/sshd_config` should include:
PermitRootLogin prohibit-password
PasswordAuthentication no
PubkeyAuthentication yes
MaxAuthTries 3
LoginGraceTime 20
AllowUsers deploy
sshd -t && systemctl reload sshd
For long-running per-IP observation, hook bans into alerts and stats:
# Export current ban list (feed WAF/firewall blacklist)
fail2ban-client status sshd | sed -n 's/.*Banned IP list:[[:space:]]*//p' | tr ' ' '\n' | grep -v '^$'
# Recent ban/unban recap
grep -E "Ban |Unban " /var/log/fail2ban.log | tail -30
Verify
- Syntax: `fail2ban-client -t` prints `OK`, no filter load errors.
- Ban: after 3 bad passwords from a test host, that IP is in `Banned IP list` from `fail2ban-client status sshd`, and `iptables -L f2b-sshd -n` shows the DROP.
- Whitelist: repeated failed auth from an `ignoreip` host must not appear in the ban list.
- Restart: after `systemctl restart fail2ban`, status again; bans restore from the DB, not wipe.
FAQ
FAQ 1: Installed, logs show fails, never bans?
Four checks in order: 1) `backend` vs actual log path. Minimal images have no `/var/log/auth.log` — set `systemd`. 2) Wrong jail name. Debian `[sshd]`, CentOS `[ssh]`. Confirm with `fail2ban-client status`. 3) `datepattern` vs log timestamps so lines never match. Run `fail2ban-regex "$(journalctl -u sshd -n 50 --no-pager)" /etc/fail2ban/filter.d/sshd.conf` and read hit counts. 4) `maxretry` too high; window never fills.
FAQ 2: My own IP got banned?
Session still up: `fail2ban-client set sshd unbanip <YOUR_IP>` immediately. Session dead: cloud console VNC or vendor rescue, same command. Real fix: office egress, jump hosts, scanner nodes in `ignoreip`. Validate thresholds on a test jail before you change prod.
FAQ 3: Docker or Kubernetes — bans do nothing?
Fail2ban inside a container talks to that netns iptables. Host INPUT is untouched. Run Fail2ban on the host with logpath pointed at host sshd logs. Or use an action that talks to cloud SGs / CDN blacklists and drop at the ingress.
FAQ 4: Do bans survive restart?
Default yes. Fail2ban writes `/var/lib/fail2ban/fail2ban.sqlite3` and restores remaining `bantime` after restart. Want a clean slate on restart: set `dbpurgeage` under `[DEFAULT]` and adjust DB policy. If the list is empty after restart, check that file was not wiped by a cleanup script, and that `dbfile` is writable.
Summary
Fail2ban vs SSH brute force lands on four things: correct log backend, sane thresholds, complete whitelist, efficient ban action. `backend = systemd` first so logs actually feed the filter. Then `findtime/maxretry/bantime` as the acceptable waterline. Jump hosts and RFC1918 in `ignoreip` so you do not self-lock. At blacklist scale, switch to ipset. Pair with sshd password-off and retry caps so brute-force cost exceeds what attackers will pay. Every change with an out-of-band channel still open. Verify ban and unban paths immediately after.