Every server with a public SSH port collects a constant rain of password guesses within hours of going online. The noise itself is harmless, but the aggregate tells a story: someone is always probing, and one weak password on one account is all the difference between a non-event and a compromise. Fail2ban turns the probing against the prober by watching the authentication log, counting failures per source address and installing temporary firewall bans on the offenders. This article builds the complete setup the way it is done on production servers: the relationship between the stock configuration and your local overrides, a properly tuned sshd jail, ban actions matched to the firewall backend, trusted addresses whitelisted so that a mistake never locks the team out, and the long-term jail that handles repeat visitors.
What fail2ban does and what it expects from you
Fail2ban is a log parser with a firewall trigger. It tails the auth log or the journal, matches lines against configurable failure patterns, counts failures per IP within a time window and, when a threshold trips, invokes a ban action that installs a firewall rule. After bantime expires it removes the rule and moves on. The design is deliberately simple, and the simplicity is what keeps it running on millions of servers for a decade without drama.
The honest boundary of the tool matters before configuration starts. Fail2ban reacts to failures that appear in logs, which means it defends the protocols whose logs it can read, and it defends them against password guessing, not against a stolen valid key. A flat-out scan of ten thousand servers with one attempt each produces no ban on yours, because per-IP thresholds never trip. The realistic threat model for the sshd jail is the classic distributed-but-persistent guesser and the occasional targeted run against a known service, and within that model fail2ban remains one of the best returns on thirty minutes of configuration anywhere in system administration.
Installation is one package on every major distribution: apt install fail2ban, dnf install fail2ban or zypper install fail2ban. After installation the service is enabled with systemctl enable --now fail2ban, and the state of every jail is visible immediately through fail2ban-client status, which lists enabled jails, and fail2ban-client status sshd, which prints the current banned list with the total count of failures that led there.
jail.conf versus jail.local and why the distinction saves your setup
The stock configuration lives in /etc/fail2ban/jail.conf, and the first rule of fail2ban administration is that this file is never edited. Package updates overwrite it without ceremony, and a year of careful tuning inside it vanishes on the next upgrade. Everything site-specific belongs in /etc/fail2ban/jail.local, which fail2ban reads after the stock file and which overrides its values key by key. The two files share the same format, so any example written for one works in the other, and the discipline costs nothing after the first week.
A third layer exists and earns its keep as setups grow: the directory /etc/fail2ban/jail.d/, where individual files can hold one jail or one concern each, for example a file called 10-sshd.local or 20-recidive.local. The layered reading order makes dropping a jail onto a fleet through configuration management trivial, because a template ships one small file instead of rewriting a monolith. For most readers starting out, a single jail.local is enough, and the directory structure is a habit to adopt when Ansible enters the picture.
One more local file deserves the name convention right away: fail2ban.local overrides the service settings themselves, most usefully the loglevel. Starting at loglevel = INFO keeps the log readable, and DEBUG exists for the day a filter stubbornly refuses to match anything and the internals need to be watched. The failure to resist the temptation to configure everything in one sitting is how jail.local files grow into three hundred line puzzles, so starting with the sshd jail and adding on demand is the sane path.
The sshd jail tuned for real conditions
The sshd jail ships disabled by default on Debian-family systems, and the shortest working jail.local that enables it looks like this:
[sshd]
enabled = true
Everything else inherits stock values, which are a reasonable starting point but deserve conscious tuning within the first week, because the defaults trade forgiveness for noise. The three parameters that define a jail's personality are maxretry, findtime and bantime, and the way they interact is worth stating out loud: more than maxretry failures from one address inside findtime trigger a ban lasting bantime. A moderate production setting looks like this:
[sshd]
enabled = true
port = ssh
backend = systemd
filter = sshd
mode = normal
maxretry = 5
findtime = 10m
bantime = 1h
The backend line is the first operational decision. On systems running systemd, backend = systemd reads the journal directly, which is faster and cleaner than tailing files and works out of the box. On distributions or containers without systemd journal access, the default backend polls log files, and pathnames in logpath keep working the classic way. The mode parameter, available in recent releases, picks which failure patterns the sshd filter matches: normal covers authentication failures, ddos adds connection-level floods, and aggressive combines both, at the cost of counting innocent reconnects from flaky mobile links as strikes. Normal plus a sane maxretry is the configuration that survives contact with real users.
Bantime choices follow a philosophy rather than a number. One hour is polite, cuts the log noise dramatically, and never punishes a colleague who typoed a password four times. Persistent offenders deserve the escalation handled by the recidive jail further down, not by a global bantime stretched to a week that eventually locks out a contractor with a bad keyboard. Recent fail2ban versions also accept time suffixes like 10m, 1h and 1d everywhere a duration is expected, which makes the file read like prose instead of seconds arithmetic.
Ban actions matched to your firewall backend
When a jail trips, fail2ban executes an action, and the action is where the tool meets the local firewall. The default on many systems, banaction = iptables-multiport, creates a chain in iptables that blocks the offending address on the specified ports. The stock actions directory, /etc/fail2ban/action.d/, contains ready-made variants for every common backend, and choosing the one that matches reality is a five-line change. The common production picks:
banaction = nftables-multiport
on modern Debian-family releases where nftables replaced the legacy layer, while on RHEL-derivatives running firewalld the matching action is firewallcmd-rich-rules or firewallcmd-ipset, and on hosts fronted by ufw the ufw action integrates bans as numbered rules visible in ufw status. The test for choosing correctly is simple: after the first ban, the blocked address must appear in the output of the firewall's own status command, not only in fail2ban's list. A ban that fail2ban believes in but the firewall never heard of is a setup that works right up until it quietly stops working.
The banaction_allports setting deserves its own line: some actions block the address on all ports instead of only the jail's ports, which is the right escalation for the recidive jail and an overreach for ordinary password mistyping. Keeping the two knobs separate, banaction for the polite hour, banaction_allports for the repeat-offender tier, produces a system whose response actually matches the severity of each event.
Whitelists that keep the team on the air
Every fail2ban deployment ships with the same embedded risk: the tool that blocks strangers cannot tell strangers from the on-call engineer having a bad night. The ignoreip parameter is the contract that resolves this, listing addresses and networks that are never banned no matter what the counters say:
[DEFAULT]
ignoreip = 127.0.0.1/8 ::1 203.0.113.0/24 198.51.100.42
Whitelist entries belong in the DEFAULT section so every jail inherits them, which is exactly what the word whitelist should mean in a security tool. The canonical list contains the loopback, the office network in CIDR form, the address of the monitoring system whose probes must never be cut, and the fixed address of the console or management box used when everything else is broken. Dynamic office links without a stable range are served by whitelisting the monitoring host and relying on it as the emergency path, since the monitoring system can always reach the server even when no human can.
The failure modes of whitelisting have two shapes, and both are worth a sentence. Too little whitelisting produces the classic 3 a.m. self-lockout, when an engineer with a flaky keyboard trips the counter and the only remaining access is the one path nobody tested in a year. Too much whitelisting produces a whitelist that contains every address anyone ever mentioned, which at that point is a firewall hole with a friendly name. The list deserves the same review cadence as the rest of the configuration: quarterly, with the same discipline as access reviews, and every entry should answer the question of which outage it exists to prevent.
The recidive jail for repeat offenders
A one-hour ban is a speed bump, and the persistent guesser simply resumes after it expires. Fail2ban ships a second-stage answer in the form of the recidive jail, which reads not the auth log but fail2ban's own log, counts how many times an address has been banned by other jails, and escalates: someone banned three times in a week earns a week or more of quiet. The stock definition reads fail2ban.log, which means one prerequisite that trips people up, namely that the service must log to a file for recidive to work, on systemd-heavy distributions this means setting logtarget = /var/log/fail2ban.log in fail2ban.local. A working recidive stanza:
[recidive]
enabled = true
backend = auto
filter = recidive
maxretry = 5
findtime = 1w
bantime = 2w
banaction = nftables-allports
Recidive also demonstrates the allports pattern from the previous section at its best: a repeat offender loses SSH and everything else, because someone probing SSH for a week is not going to behave better on the web port. The jail becomes visible in fail2ban-client status only after the first offender graduates into it, so an empty list during the first days is expected rather than a misconfiguration.
Testing, unbanning and the field checklist
A setup is real after it has been tested, and the test procedure is short enough to run on every new server. Check the service is parsing the configuration with fail2ban-client status and fail2ban-client get sshd actions, which prints the action as it will actually execute. Generate a few genuine failures from a non-whitelisted address, mistyped passwords or refused keys, and watch the counter climb in the log until the ban fires. Confirm the ban landed in the firewall itself, with the status command of whichever backend is in use. Then lift the test ban by hand, fail2ban-client set sshd unbanip 198.51.100.42, which is the same command that rescues a locked-out colleague when the whitelist was one address short.
Monitoring closes the loop, because an unwatched fail2ban quietly becomes either a dead service or a full-time blocker. A daily glance at fail2ban-client status sshd with its currently banned list, or an exported metric if the server reports to a dashboard, keeps the difference between a quiet box and a broken one visible. The all-clear signal on any server is boring: a small rotating list of banned strangers, an empty recidive list most weeks, and a whitelist that nobody has had to emergency-edit since the quarterly review.
The checklist version of this entire article fits into the first hour of any new server. Install, enable, write a minimal jail.local with the sshd jail tuned as shown, set the backend to match the init system, choose the banaction to match the firewall, and load the whitelist before the first ban ever fires. Add recidive once the basic jail has run for a week. Test the whole ladder with real failures, learn the unban command by using it once, and then let the counter do its quiet work on the background noise of the public internet.