How to configure Fail2ban
The first thing a small public server teaches you is that nobody needs to know you exist before they start knocking. Put SSH on the internet, wait a few hours, and the logs will usually fill with usernames nobody on your machine has ever used, from IP addresses you have never seen, at a pace that feels almost personal.
Fail2ban is not magic security dust. It will not fix weak passwords, exposed admin panels, forgotten services, or a firewall that allows too much. What it does very well is turn a noisy, repetitive class of attacks into a boring automated reaction. Read logs, notice abuse, add a firewall rule, remove it later. That is a small loop, but on a homelab or small VPS it changes the whole texture of operating the thing.
Why Fail2ban still earns its place
There is a fashionable answer to brute-force traffic: hide the service behind a VPN, disable password login, use SSH keys, restrict source IPs, and avoid exposing anything that does not need daylight. That answer is correct. It is also not the whole story.
Real systems drift. A staging box gets a temporary rule. A reverse proxy exposes a route before authentication is finished. A media server has a login screen. A password manager sits behind Nginx and should be protected by several layers, not one heroic layer. Fail2ban is useful because it watches the layer where attackers announce themselves: the logs.
Its model is simple. A jail connects a service, a log path, a filter, and a punishment. The filter recognizes repeated failures. The jail decides how many failures are too many, how long the detection window is, and how long a ban should last. The action then updates the firewall, or sends a notification, or both.
That simplicity is the reason it has survived for so long. You can explain the production behavior without needing to explain a new control plane.
Start with SSH, because SSH starts the noise
On a Debian or Ubuntu host, the basic installation is refreshingly uninteresting.
sudo apt update
sudo apt install fail2ban
On RHEL-family systems, make sure the package source is available first.
sudo dnf install epel-release
sudo dnf install fail2ban
Modern Fail2ban setups are easiest to reason about when local overrides live in separate files under /etc/fail2ban/jail.d/. Copying the global jail.conf into jail.local still works, but it encourages one large file that quietly becomes the place where every exception goes to hide.
A focused SSH jail is a better first move.
[sshd]
enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
findtime = 600
bantime = 3600
This says that three matching failures inside ten minutes earn the source IP a one-hour timeout. The exact numbers are less important than the intent. You want a threshold low enough to stop automation and high enough to avoid punishing a real admin for one mistyped key passphrase followed by one distracted retry.
Before enabling anything, add addresses you trust to the default ignore list. Include loopback, your LAN, and any private overlay network you actually use for administration.
[DEFAULT]
ignoreip = 127.0.0.1/8 ::1 192.168.1.0/24 100.64.0.0/10
That last range is common for Tailscale. Keep it only if it matches your environment. The fastest way to turn Fail2ban from a helper into a small afternoon of pain is to ban the network you need for recovery.
Test the config before you trust it
Fail2ban gives you enough inspection tools that you do not have to fly blind. Validate the configuration first, then start the service, then inspect the active jails.
sudo fail2ban-client -t
sudo systemctl enable --now fail2ban
sudo fail2ban-client status
sudo fail2ban-client status sshd
The first command catches configuration errors. The status commands confirm that Fail2ban is running and that the sshd jail is actually loaded. If a jail is missing, do not assume Fail2ban is broken. Check the file name, the section name, and the log path. On some distributions, SSH auth logs may live somewhere other than /var/log/auth.log.
For a quick manual test, ban and unban a harmless test address rather than your own workstation.
sudo fail2ban-client set sshd banip 192.0.2.123
sudo fail2ban-client set sshd unbanip 192.0.2.123
The 192.0.2.0/24 block is reserved for documentation, which makes it a good placeholder in examples. Use a real unwanted IP only when you know what you are doing.
Add the reverse proxy after SSH
Once SSH is quiet, the next useful target is usually the reverse proxy. Nginx sits in front of many homelab services, so a small number of jails can protect a surprisingly large surface area.
[nginx-http-auth]
enabled = true
filter = nginx-http-auth
port = http,https
logpath = /var/log/nginx/error.log
maxretry = 5
bantime = 3600
[nginx-noscript]
enabled = true
filter = nginx-noscript
port = http,https
logpath = /var/log/nginx/access.log
maxretry = 6
bantime = 3600
These jails are not a replacement for application-level authentication. They are pressure relief. If a bot keeps trying basic-auth credentials or probes common script paths, you do not need your applications to receive every single attempt.
Be careful with shared IPs and carrier-grade NAT. A strict web jail can block several legitimate users if they appear behind the same public address. For personal infrastructure that is often acceptable. For anything customer-facing, tune slowly and watch the logs before getting enthusiastic.
Send notifications, but do not build a siren
Notifications are useful because they close the loop. A ban tells you which services are being touched, whether an exposed endpoint is attracting attention, and whether a new rule is too aggressive. Discord, Slack, email, or a webhook receiver can all work.
The important part is to keep the notification boring. Send the jail, IP, host, failure count, and action. Avoid secrets, headers, request bodies, or anything you would not want copied into a chat tool.
#!/usr/bin/env bash
set -euo pipefail
webhook_url="https://discord.com/api/webhooks/replace/me"
action="$1"
ip="$2"
jail="$3"
host="$4"
failures="${5:-unknown}"
payload=$(printf '{"username":"Fail2ban","content":"%s: %s in %s on %s after %s failures"}' \
"$action" "$ip" "$jail" "$host" "$failures")
curl --silent --show-error \
--request POST \
--header "Content-Type: application/json" \
--data "$payload" \
"$webhook_url" > /dev/null
Then wire it as a Fail2ban action.
[Definition]
actionban = /etc/fail2ban/scripts/notify.sh ban <ip> <name> <fq-hostname> <failures>
actionunban = /etc/fail2ban/scripts/notify.sh unban <ip> <name> <fq-hostname>
Test the script directly before putting it into a jail. A notification hook that fails loudly can make troubleshooting bans much harder than it needs to be.
Tune for operations, not drama
The default temptation is to make bans long and thresholds tiny. That feels satisfying until the first legitimate lockout, dynamic IP change, or shared office network gets caught in the net. Start with short bans, watch what happens, then make the punishment longer only when the signal is clean.
For a private homelab, I like a stricter SSH jail and a more cautious web jail. SSH failures are usually easier to interpret. Web logs are messier because scanners, health checks, crawlers, auth flows, and reverse proxy behavior all mingle together.
Also decide where Fail2ban belongs in the stack. If your firewall is managed by nftables, make sure Fail2ban is using the matching backend. If Docker publishes ports directly, understand whether the packet path hits the chains you expect. If your service logs live inside containers, make those logs available to the host or run Fail2ban where it can actually read them.
Keep the bigger security story intact
The nice thing about Fail2ban is that it rewards boring maintenance. You add a jail, confirm the logs, test the action, and let it run. A week later, you have evidence. Maybe SSH is taking all the heat. Maybe your reverse proxy is getting sprayed by script probes. Maybe a service you thought was obscure is not obscure at all.
Fail2ban will not make a fragile server safe. But it can make a sensible server calmer. For a homelab, that is a pretty good bargain.
Happy banning!