Hardening Your Self-Hosted Apps with Fail2Ban and CrowdSec

Hardening Your Self-Hosted Apps with Fail2Ban and CrowdSec

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 — whether it's Vaultwarden, Nextcloud, Gitea, or a plain SSH port — the bots find it within minutes. I've watched Fail2Ban log files on fresh Hetzner and DigitalOcean droplets fill up with hundreds of failed SSH attempts before I'd even finished the initial setup. Running Fail2Ban alongside CrowdSec gives you two complementary layers: Fail2Ban handles reactive, log-based banning on the local machine, while CrowdSec adds a crowd-sourced threat intelligence layer that blocks known bad actors before they even try. This tutorial walks through setting up both tools on an Ubuntu 24.04 server, integrating them with Docker-based self-hosted services, and making sure they don't step on each other.

Why Both Tools? Fail2Ban vs. CrowdSec at a Glance

Fail2Ban has been the default answer to brute-force protection for well over a decade, and it still works brilliantly for single-server setups. It watches log files, matches regex patterns, and calls iptables (or nftables) to insert a DROP rule for offending IPs. It's simple, battle-tested, and ships with ready-made "jails" for SSH, Nginx, Apache, and dozens of other services.

CrowdSec is the newer, community-driven alternative. It parses logs similarly to Fail2Ban but also shares signals with a global network of agents — so if an IP is hammering someone else's Nextcloud instance right now, your server can pre-emptively block it. CrowdSec separates the detection engine from the blocking component (called a "bouncer"), which means it integrates cleanly into firewall rules, reverse proxies like Caddy or Traefik, and even Cloudflare.

I run both. Fail2Ban covers edge cases where I've written custom jails for obscure apps, and CrowdSec handles the heavy lifting for well-known attack patterns with its threat intelligence feeds.

Step 1: Install and Configure Fail2Ban

On Ubuntu 24.04, installation is straightforward:

# Install Fail2Ban
sudo apt update && sudo apt install fail2ban -y

# Copy the default config to a local override — never edit jail.conf directly
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local

# Enable and start the service
sudo systemctl enable --now fail2ban

# Check status
sudo fail2ban-client status

Now edit /etc/fail2ban/jail.local to configure your SSH jail and a custom jail for Nginx (or whichever reverse proxy sits in front of your apps). I always set a fairly aggressive ban time for SSH — 10 minutes is the default, but I push it to an hour for most of my servers:

# /etc/fail2ban/jail.local  — relevant sections only

[DEFAULT]
bantime  = 3600       # 1 hour ban
findtime = 600        # 10 minute detection window
maxretry = 5          # 5 failures triggers a ban
backend  = systemd    # Use systemd journal on Ubuntu 24.04

[sshd]
enabled  = true
port     = ssh
logpath  = %(sshd_log)s
maxretry = 3          # Be stricter on SSH

[nginx-http-auth]
enabled  = true
port     = http,https
logpath  = /var/log/nginx/error.log

[nginx-limit-req]
enabled  = true
port     = http,https
logpath  = /var/log/nginx/error.log
maxretry = 10

# Custom jail for Vaultwarden (reads JSON logs via filter)
[vaultwarden]
enabled   = true
port      = http,https
logpath   = /opt/vaultwarden/data/vaultwarden.log
filter    = vaultwarden
maxretry  = 5
bantime   = 7200

For the Vaultwarden filter, create /etc/fail2ban/filter.d/vaultwarden.conf:

[Definition]
failregex = ^.*Username or password is incorrect\. Try again\. IP: .*$
            ^.*[Ff]ailed login attempt.*IP: .*$
ignoreregex =

After any config change, reload with sudo fail2ban-client reload and check that your jails are active with sudo fail2ban-client status sshd.

Watch out: If your self-hosted apps run inside Docker containers and log to Docker's JSON driver rather than a flat file, Fail2Ban can't read those logs directly. Either configure your containers to log to a bind-mounted file path, use logpath = /var/lib/docker/containers/*/*.log with an appropriate filter, or use CrowdSec's Docker log acquisition instead — which handles this much more gracefully.

Step 2: Install CrowdSec

CrowdSec isn't in the default Ubuntu repos, so you add their official repository:

# Add the CrowdSec repository
curl -s https://packagecloud.io/install/repositories/crowdsec/crowdsec/script.deb.sh | sudo bash

# Install CrowdSec and the firewall bouncer
sudo apt install crowdsec crowdsec-firewall-bouncer-iptables -y

# Check the service is running
sudo systemctl status crowdsec

# List installed collections (parsers + scenarios)
sudo cscli collections list

CrowdSec auto-detects many common services during installation. You can inspect what it found and add more collections for your specific stack:

# Add collections for your services
sudo cscli collections install crowdsecurity/nginx
sudo cscli collections install crowdsecurity/sshd
sudo cscli collections install crowdsecurity/nextcloud
sudo cscli collections install crowdsecurity/vaultwarden

# Reload CrowdSec to pick up new collections
sudo systemctl reload crowdsec

# Check active scenarios
sudo cscli scenarios list

# See current decisions (bans)
sudo cscli decisions list

The firewall bouncer integrates with iptables and creates a chain called CROWDSEC_CHAIN. Any IP that CrowdSec decides to ban gets a DROP rule inserted there automatically — no manual intervention needed.

Step 3: Configuring CrowdSec Log Acquisition for Docker Services

This is where CrowdSec genuinely shines over Fail2Ban for Docker setups. Edit /etc/crowdsec/acquis.yaml (or drop a file in /etc/crowdsec/acquis.d/) to point CrowdSec at your container logs:

# /etc/crowdsec/acquis.d/docker-services.yaml

---
source: docker
container_name:
  - nextcloud-app
  - vaultwarden
  - gitea
labels:
  type: nginx
---
source: docker
container_name:
  - caddy
labels:
  type: caddy
---
source: file
filenames:
  - /var/log/auth.log
labels:
  type: syslog

After saving, restart CrowdSec: sudo systemctl restart crowdsec. Then run sudo cscli metrics to confirm it's actually reading from those sources and incrementing line counts.

Tip: CrowdSec has a free console at app.crowdsec.net where you can register your instance with sudo cscli console enroll <your-enroll-key>. This gives you a web dashboard showing live alerts, banned IPs, and your contribution to the community blocklist — genuinely useful for a homelab running multiple services.

Step 4: Making Fail2Ban and CrowdSec Play Nicely Together

Running both tools is fine because they write to different iptables chains (f2b-* vs CROWDSEC_CHAIN). The only thing to avoid is double-logging the same event or having one tool unban what the other has banned. My approach: use CrowdSec for all HTTP/application-layer traffic where it has good collections, and keep Fail2Ban focused on SSH and any niche services that don't have a CrowdSec collection yet. You can even disable the Fail2Ban nginx-* jails once CrowdSec is confidently covering those, reducing redundancy.

To manually test that banning works, use sudo cscli decisions add --ip 192.0.2.1 --duration 5m --reason "test ban" and then verify with sudo iptables -L CROWDSEC_CHAIN -n. You should see the IP listed with a DROP target. Remove the test with sudo cscli decisions delete --ip 192.0.2.1.

Step 5: Whitelisting Your Own IPs

Before you get too aggressive with ban thresholds, whitelist your own IPs and your Tailscale or WireGuard subnet so you don't accidentally lock yourself out. In Fail2Ban, add to jail.local:

[DEFAULT]
ignoreip = 127.0.0.1/8 ::1 100.64.0.0/10
# 100.64.0.0/10 covers the Tailscale CGNAT range

In CrowdSec, use the allowlists feature:

# /etc/crowdsec/allowlists.yaml
---
name: my-trusted-ips
description: My home and Tailscale IPs
ips:
  - 100.64.0.0/10
  - 127.0.0.1

Monitoring and Day-to-Day Management

Once everything is running, I check in with a few quick commands during my weekly homelab review. sudo fail2ban-client status gives a summary of all active jails and ban counts. sudo cscli decisions list shows active CrowdSec bans with their source and expiry. For a longer-term view, I pipe CrowdSec metrics into my Prometheus/Grafana stack using the crowdsec-prometheus metrics endpoint on port 6060 — the CrowdSec documentation has a ready-made Grafana dashboard you can import with a single dashboard ID.

If you're running everything on a DigitalOcean Droplet, this combination of Fail2Ban and CrowdSec pairs well with UFW (which should be your first line of defense, only opening ports 22, 80, and 443) and Cloudflare proxying for your public-facing services. Layered security isn't paranoia — it's just good practice when you're responsible for storing real data.

Wrapping Up

After running this setup across several VPS instances and home servers for the past year, I can confidently say the combination of Fail2Ban and CrowdSec catches the vast majority of automated attacks without generating false positives that lock out legitimate users. The key is starting with conservative ban thresholds, whitelisting your own access paths first, and then tightening up once you've watched the logs for a week and understand your traffic patterns.

Your next steps: enable the CrowdSec community blocklist subscription (sudo cscli hub update && sudo cscli hub upgrade keeps your scenarios current), and consider adding a Traefik or Caddy bouncer if you're running those as your reverse proxy — both have official CrowdSec bouncer plugins that block at the proxy layer before requests even reach your containers.

Discussion