Securing Your Self-Hosted Apps with Fail2Ban and Rate Limiting
We earn commissions when you shop through the links on this page, at no additional cost to you. Learn more.
The moment you expose a self-hosted app to the internet, the bots find it. I've watched Vaultwarden instances get hammered with credential-stuffing attempts within 20 minutes of going live, and Gitea login pages absorb thousands of failed logins a day from automated scanners. Two tools stop most of this noise dead: Fail2Ban for reactive IP banning and rate limiting at your reverse proxy for proactive throttling. Used together, they form a lightweight but surprisingly robust first line of defence.
This tutorial walks through installing and configuring Fail2Ban with custom jails for common self-hosted apps, then adds rate limiting at the Nginx and Caddy layers. Everything runs on a standard Ubuntu 22.04 or 24.04 VPS — the same setup I use across several DigitalOcean Droplets running Nextcloud, Immich, and Gitea.
Installing Fail2Ban
Fail2Ban is in the standard Ubuntu repos, so installation is straightforward. I always grab the latest version from apt and immediately enable it at boot:
sudo apt update && sudo apt install -y fail2ban
sudo systemctl enable --now fail2ban
sudo systemctl status fail2ban
Fail2Ban ships with a /etc/fail2ban/jail.conf file you should never edit directly — it gets overwritten on upgrades. Instead, create a jail.local override file. Here's the baseline configuration I use on every server:
sudo nano /etc/fail2ban/jail.local
[DEFAULT]
# Ban IPs for 1 hour on first offence
bantime = 3600
# Look back 10 minutes for failures
findtime = 600
# Allow 5 failures before banning
maxretry = 5
# Use systemd backend for journald logs
backend = systemd
# Send ban notifications (optional — set your email below)
destemail = [email protected]
sendername = Fail2Ban
mta = sendmail
action = %(action_mw)s
# SSH is the most important jail — enable it first
[sshd]
enabled = true
port = ssh
filter = sshd
maxretry = 3
bantime = 86400
After saving, reload Fail2Ban and check that the SSH jail is active:
sudo systemctl reload fail2ban
sudo fail2ban-client status sshd
You should see Currently banned: 0 and Total banned: 0 — unless someone's already tried to log in.
Writing Custom Jails for Self-Hosted Apps
The real power comes from writing jails tailored to your apps. Fail2Ban works by matching log lines against a regex filter, then acting when the match count exceeds maxretry within findtime. Let me walk through jails for three common apps: Nextcloud, Vaultwarden, and Gitea.
First, create the filter files. Nextcloud writes authentication failures to its own log:
sudo nano /etc/fail2ban/filter.d/nextcloud.conf
[Definition]
_groupsre = (?:(?:,?\s*"\w+":(?:"[^"]*"|\w+))*)
failregex = ^\{%(_groupsre)s,?\s*"remoteAddr":""%(_groupsre)s,?\s*"message":"Login failed:
ignoreregex =
Vaultwarden logs to stdout (and therefore to the Docker journal or a log file depending on your setup). This filter catches failed login attempts:
sudo nano /etc/fail2ban/filter.d/vaultwarden.conf
[Definition]
failregex = ^.*Username or password is incorrect\. Try again\. IP: \. Username:.*$
ignoreregex =
Now wire both up in jail.local — append these blocks to the file you created earlier:
[nextcloud]
enabled = true
port = http,https
filter = nextcloud
logpath = /var/lib/docker/volumes/nextcloud_data/_data/nextcloud.log
maxretry = 5
bantime = 3600
findtime = 300
[vaultwarden]
enabled = true
port = http,https
filter = vaultwarden
# If using Docker logging to a file:
logpath = /opt/vaultwarden/logs/vaultwarden.log
# If using journald (Docker with json-file driver):
# backend = systemd
maxretry = 3
bantime = 7200
findtime = 300
[gitea]
enabled = true
port = http,https
filter = gitea
logpath = /opt/gitea/log/gitea.log
maxretry = 5
bantime = 3600
findtime = 300
sudo fail2ban-regex /path/to/your.log /etc/fail2ban/filter.d/yourfilter.conf and check that the "Lines: X matched" count is non-zero. A filter that matches nothing gives you false confidence while doing zero work.Reload and verify all jails are active:
sudo systemctl reload fail2ban
sudo fail2ban-client status
Rate Limiting with Nginx
Fail2Ban is reactive — it bans after failures happen. Rate limiting is proactive — it throttles requests before damage is done. I add this to every Nginx reverse proxy config that faces the internet.
Define a rate limit zone in your /etc/nginx/nginx.conf inside the http block:
# In /etc/nginx/nginx.conf — inside the http {} block
# Allow 10 requests per second per IP, zone stored in 10MB shared memory
limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;
# Tighter zone for login endpoints — 1 request per second
limit_req_zone $binary_remote_addr zone=login:10m rate=1r/s;
Then apply the zones in your virtual host configs. Here's a Nextcloud example in /etc/nginx/sites-available/nextcloud.conf:
server {
listen 443 ssl;
server_name cloud.yourdomain.com;
# Apply general rate limit to all requests
limit_req zone=general burst=20 nodelay;
# Return 429 (Too Many Requests) instead of default 503
limit_req_status 429;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Hammer login and OIDC endpoints with the tight zone
location ~ ^/(login|ocs/v[12]\.php/cloud/users) {
limit_req zone=login burst=5 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
Test and reload Nginx:
sudo nginx -t && sudo systemctl reload nginx
Rate Limiting with Caddy
If you prefer Caddy (and I often do — the automatic HTTPS is hard to beat), rate limiting requires the caddy-ratelimit plugin. The easiest way to get it is via xcaddy:
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
xcaddy build --with github.com/mholt/caddy-ratelimit
sudo mv ./caddy /usr/bin/caddy
Then in your Caddyfile:
cloud.yourdomain.com {
# General rate limit: 20 requests per second per IP
rate_limit {remote.ip} 20r/s
handle /login* {
# Tight limit on login path: 2 requests per second
rate_limit {remote.ip} 2r/s
reverse_proxy localhost:8080
}
reverse_proxy localhost:8080
}
$remote_addr in Nginx (or {remote.ip} in Caddy) will be the CDN's IP, not the visitor's. You'll rate-limit all traffic equally and potentially lock yourself out. Always restore real visitor IPs first — in Nginx that means the ngx_http_realip_module and set_real_ip_from directives; in Caddy use the trusted_proxies option.Banning Repeat Offenders More Aggressively
One thing I added to my setup after getting tired of the same subnets cycling back in: progressive banning. Fail2Ban supports a recidive jail that watches the ban log itself and escalates repeat offenders to a much longer ban:
# Append to /etc/fail2ban/jail.local
[recidive]
enabled = true
filter = recidive
logpath = /var/log/fail2ban.log
action = %(action_mw)s
bantime = 604800 # 1 week
findtime = 86400 # Look back 24 hours
maxretry = 3 # 3 bans in 24h = 1 week ban
This catches the persistent scanners that just wait out a 1-hour ban and try again. A week-long ban combined with Nginx rate limiting makes automated attacks essentially pointless.
Useful Fail2Ban Commands for Day-to-Day Management
# List all active jails
sudo fail2ban-client status
# See banned IPs for a specific jail
sudo fail2ban-client status nextcloud
# Manually unban an IP you've locked yourself out from
sudo fail2ban-client set sshd unbanip 203.0.113.42
# Check what Fail2Ban would do with a log line (dry-run testing)
sudo fail2ban-regex /var/log/auth.log /etc/fail2ban/filter.d/sshd.conf
# Watch ban events in real time
sudo tail -f /var/log/fail2ban.log | grep -i ban
Where to Host All This
This entire stack — Fail2Ban, Nginx with rate limiting, Nextcloud, Vaultwarden — runs comfortably on a $6/month Droplet. Start spending more time on your projects and less time managing your infrastructure. Create your DigitalOcean account today. Their Droplets spin up in under a minute and the built-in cloud firewall gives you a hardware-level deny-by-default perimeter that complements everything covered here.
Putting It All Together
The combination of Fail2Ban jails and reverse proxy rate limiting handles the vast majority of automated attacks you'll face as a self-hoster. Fail2Ban catches the bots that make it through and bans them at the IP level; rate limiting ensures that even if a ban hasn't triggered yet, no single IP can flood your login endpoints. The recidive jail deals with the persistent ones.
My suggested next steps: first, verify your jails are actually matching real log lines using fail2ban-regex before assuming protection is in place. Second, pair this setup with UFW to ensure only ports 80, 443, and your SSH port are open — Fail2Ban and rate limiting can't protect a port that shouldn't be public in the first place. Check out our tutorial on securing your VPS with UFW and Fail2Ban for the firewall layer, and Authelia behind a reverse proxy if you want to add SSO and MFA on top.
Discussion