
Contents (6 sections)
Expose an SSH port to the Internet and look at the logs: attempts arrive continuously, by the thousand, from botnets cycling through username and password pairs. Fail2ban is the classic answer: read the logs, spot repeated failures, ban the source address. Five minutes to install, and that is precisely the problem: most installs stop at the tutorial, with a default SSH jail that reassures more than it protects. Here is what a production configuration changes, pitfalls we have lived through included.
Putting fail2ban back in its place #
Let us say it clearly first: fail2ban is a noise reducer, not a primary defence. If your SSH accepts passwords, fail2ban slows attackers down; if your SSH only accepts keys (PasswordAuthentication no), brute force is already dead and fail2ban merely cleans up the logs and saves resources. The order of priorities is therefore: key-only authentication, minimal exposure (a firewall that only opens what is needed, IP allowlists for administration), and then fail2ban for the residual noise. That is the architecture of our front-end servers: the hypervisor firewall only lets public HTTP and HTTPS through, SSH uses keys, and fail2ban cleans up what is left.
The 2026 baseline: journald, not auth.log #
The first trap of older tutorials: they point fail2ban at /var/log/auth.log… which no longer exists on modern systems without rsyslog, where everything lives in the systemd journal. A jail that reads a missing or frozen file bans nobody, silently. The fix: backend = systemd. Here is our base jail.local, with the second setting that changes everything, progressive banning:
# /etc/fail2ban/jail.local
[DEFAULT]
backend = systemd
banaction = nftables-multiport
# Progressive banning: each repeat offence multiplies the penalty.
bantime = 1h
bantime.increment = true
bantime.factor = 2
bantime.maxtime = 1w
findtime = 10m
maxretry = 5
# IPs to NEVER ban: monitoring, admin. Short, justified list.
ignoreip = 127.0.0.1/8 ::1 <monitoring-ip>
[sshd]
enabled = true
mode = aggressiveProgressive banning is what makes the difference over time: an occasional scanner gets an hour, a repeat offender ends up banned for a week. And always check that a jail is actually working: fail2ban-client status sshd should show matches that keep increasing. A jail stuck at zero for weeks on an exposed server is a broken jail, not a server that has been spared.
One level up: HTTP jails behind a reverse proxy #
Brute force does not target SSH alone: web application login pages, API endpoints and admin paths get the same traffic. Fail2ban can protect them, provided it reads the reverse proxy logs (Traefik, nginx…) and matches bursts of 401 and 403 responses. Two non-negotiable conditions, both learned the hard way:
- Ban the real client IP, never the proxy’s. If your application sits behind a reverse proxy (or worse, if Docker does NAT in front of it), the logs may only show the proxy’s internal IP. Ban it and you cut off all traffic. The chain must preserve the source address from end to end; on one of our servers, this meant switching the proxy to host networking, otherwise IPv6 visitors all showed up with the same internal Docker gateway IP.
- Structured logs. A JSON access log can be parsed without fragile regular expressions: the fail2ban filter becomes one reliable line instead of an expression that breaks at the first change to the log format.
The two pitfalls that cost dearly #
The corporate proxy: one IP means dozens of people #
Seen in production: bursts of suspicious requests from a single IP, the reflex to ban… and it was a corporate proxy (Zscaler or similar): behind that address, dozens of legitimate employees, possibly including your own client. The same goes for external monitoring probes, whose behaviour (regular requests, varied ports) looks exactly like a scan. Before banning a “strange” IP, a whois takes ten seconds; corporate proxies and your probes go into ignoreip, documented, with the reason in a comment.
nftables: the restart that wipes everything #
Fail2ban writes its bans into the firewall (nftables nowadays). But systemctl restart nftables wipes the entire ruleset before reloading the file, taking fail2ban’s dynamic tables with it and, if Docker is running, its NAT rules: we have seen a harmless-looking restart cut off DNS for a production container. To reload an nftables configuration, use nft -f (atomic, additive), never a service restart on a machine that runs fail2ban or Docker.
Fail2ban illustrates our conviction well: security is not a product you install, it is a series of details you check. That is what our managed services cover: hardening, monitoring of jails, and immutable backups for the day everything else has failed. Any doubt about the real exposure of your servers? Let us talk.



