Most homelab security boils down to “don’t expose ports” and “use strong passwords.” That works until you run services that need to be reachable. SSH, Proxmox web UI, nginx for your sites. Those get scanned constantly.
fail2ban is the usual answer. It watches logs and bans IPs after N failed attempts. It works, but it’s reactive and local. Every machine fights its own battle. Nobody shares intel. Your Proxmox host has no idea that the IP hammering your VPS is the same one that just hit three other services on your network.
CrowdSec fixes this. It’s a modern intrusion detection system with a central brain (the Local API, or LAPI) that collects alerts from log processors and pushes block decisions to enforcement points called bouncers. One machine detects an attack, every machine in your network blocks it. And if you opt into the community network (CAPI), you get shared threat intel from thousands of other CrowdSec instances.
Architecture
Here’s what we’re building: a CrowdSec engine in Docker on a Proxmox VM, with bouncers on the Proxmox host (nftables), the MikroTik router, and Traefik as both a log source and bouncer for web traffic.
Four moving parts:
- CrowdSec Engine + LAPI in Docker, processing logs and making decisions
- Traefik acting as both a log source (feeding access logs for HTTP attack detection) and a bouncer (blocking bad IPs at the reverse proxy layer before requests hit your services)
- nftables bouncer on the Proxmox host, blocking at the hypervisor level
- RouterOS bouncer feeding blocklists to the MikroTik, blocking at the network edge
Part 1: CrowdSec Engine in Docker
I run this on a Debian VM inside Proxmox. A lightweight VM works fine, 2 vCPUs and 2GB RAM is plenty for a homelab. Traefik also runs on this VM, reverse-proxying web traffic to containers and other internal services.
Create the directory structure:
mkdir -p /opt/crowdsec/{data,config,acquis}
The docker-compose.yml:
services:
crowdsec:
image: crowdsecurity/crowdsec:latest
container_name: crowdsec
restart: unless-stopped
ports:
- "0.0.0.0:8080:8080"
environment:
COLLECTIONS: "crowdsecurity/linux crowdsecurity/sshd crowdsecurity/traefik crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules"
TZ: "Asia/Kolkata"
BOUNCER_KEY_proxmox: "replace-with-a-long-random-string"
BOUNCER_KEY_mikrotik: "replace-with-another-long-random-string"
BOUNCER_KEY_traefik: "replace-with-a-third-random-string"
volumes:
- /opt/crowdsec/data:/var/lib/crowdsec/data
- /opt/crowdsec/config:/etc/crowdsec
- /opt/crowdsec/acquis.yaml:/etc/crowdsec/acquis.yaml:ro
- /var/log:/var/log/host:ro
- /opt/traefik/logs:/var/log/traefik:ro
A few things worth explaining:
0.0.0.0:8080:8080. The LAPI needs to listen on all interfaces. Bouncers on other machines will hit this. Don’t expose this port to the internet. It stays inside your LAN.
BOUNCER_KEY_proxmox and BOUNCER_KEY_mikrotik. These pre-create API keys for your bouncers. If you skip this, you’ll generate keys later with cscli bouncers add. I prefer defining them here so the keys survive container rebuilds.
/var/log:/var/log/host:ro. This is how the engine reads host logs. Mount your log directories as read-only. The engine never writes to them.
Acquisition config
Create /opt/crowdsec/acquis.yaml:
# SSH attempts
source: file
filenames:
- /var/log/host/auth.log
labels:
type: syslog
# If you run nginx on this VM
---
source: file
filenames:
- /var/log/host/nginx/access.log
labels:
type: nginx
# Traefik access logs
---
source: file
filenames:
- /var/log/traefik/access.log
labels:
type: traefik
# CrowdSec AppSec component for real-time WAF
---
source: appsec
listen_addr: 0.0.0.0:7422
appsec_configs:
- crowdsecurity/appsec-default
labels:
type: appsec
The labels.type field tells CrowdSec which parser to use. syslog handles auth.log (SSH, sudo failures). nginx handles web server logs.
Override LAPI config
Mount a custom config to let LAPI listen properly and enable auto-registration for any future log processors:
Create /opt/crowdsec/config/config.yaml:
api:
server:
listen_uri: 0.0.0.0:8080
auto_registration:
enabled: true
token: "a-long-random-token-at-least-32-chars"
allowed_ranges:
- 192.168.0.0/24
- 10.0.0.0/24
- 172.16.0.0/16
Bring it up:
docker compose up -d
Check it’s alive:
docker exec crowdsec cscli metrics
If you see the acquisition feed running without errors, you’re good.
Part 2: Traefik as Log Source and Bouncer
Traefik is the sweet spot for CrowdSec integration. It generates structured access logs that CrowdSec can mine for HTTP attacks, and it can also act as a bouncer, blocking bad IPs before requests ever reach your backend services. At the reverse proxy layer, you get both detection and enforcement.
Log source: feeding Traefik access logs to CrowdSec
First, enable Traefik’s access log in JSON format. In your Traefik static config or Docker labels:
accessLog:
filePath: /opt/traefik/logs/access.log
format: json
Mount the logs directory into the CrowdSec container. I already added /opt/traefik/logs:/var/log/traefik:ro to the compose file in Part 1.
Install the Traefik collection:
docker exec crowdsec cscli collections install crowdsecurity/traefik
The traefik collection ships with parsers for the access log format and scenarios tuned for HTTP attacks: aggressive crawlers, path traversal probes, SQL injection attempts, bad user agents, admin panel scanners. It’s a solid set of detections out of the box.
A word about log rotation
Traefik doesn’t rotate its access log on its own. If you leave it, it’ll grow until your disk fills up. Use logrotate:
/opt/traefik/logs/access.log {
daily
rotate 7
compress
delaycompress
missingok
notifempty
create 0644 root root
sharedscripts
postrotate
docker kill --signal="USR1" traefik 2>/dev/null || true
endscript
}
Sending USR1 to Traefik makes it reopen the log file after rotation. Without this, Traefik keeps writing to the old file descriptor and CrowdSec stops seeing new entries.
Bouncer: blocking at the proxy layer
The CrowdSec Bouncer Traefik Plugin (by maxlerebourg and team) runs as a Traefik middleware. Every HTTP request goes through it. If the client IP is on CrowdSec’s blocklist, Traefik returns 403 before the request touches your backend.
Install the plugin via Traefik’s static config:
experimental:
plugins:
bouncer:
moduleName: github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
version: v1.4.8
Then define a middleware in your dynamic config:
http:
middlewares:
crowdsec:
plugin:
bouncer:
enabled: true
crowdsecMode: live
crowdsecLapiUrl: http://crowdsec:8080
crowdsecLapiKey: "replace-with-traefik-key"
crowdsecAppsecEnabled: true
crowdsecAppsecHost: crowdsec:7422
crowdsecAppsecFailureBlock: true
What each field does:
crowdsecMode: live: Queries the LAPI for every request. The alternative isstreammode, which syncs decisions locally. For a single Traefik instance,liveis simpler.crowdsecLapiUrl: Points to the CrowdSec container on the Docker network.crowdsecLapiKey: TheBOUNCER_KEY_traefikvalue from your compose file.crowdsecAppsecEnabled: true: Enables the AppSec WAF component. This gives you real-time HTTP inspection: virtual patching for known CVEs, generic attack detection (SQLi, XSS, path traversal). Requests matching a rule get blocked with a 403.crowdsecAppsecHost: crowdsec:7422: The AppSec component port defined in the acquisition config.crowdsecAppsecFailureBlock: true: If the AppSec component is unreachable, block the request instead of letting it through. Default-deny on failure.
To apply the middleware globally, add it to an entrypoint or a router:
# Apply to all routers on this entrypoint
entryPoints:
websecure:
address: ":443"
http:
middlewares:
- crowdsec@file
Or per-router:
http:
routers:
my-service:
rule: "Host(`service.example.com`)"
middlewares:
- crowdsec@file
Why run both Traefik and firewall bouncers?
The Traefik bouncer catches HTTP-layer attacks and blocks them at the proxy. But it only sees web traffic. SSH brute force, database connection attempts, port scans. Those bypass Traefik entirely. That’s why you keep the nftables bouncer on the Proxmox host. Each bouncer defends its layer.
Watch out for false positives
The http-probing scenario in the traefik collection can be aggressive. It triggers on a burst of 404/403 responses from one IP. I got my own IP banned after pushing Docker images to a Forgejo registry. Docker’s push makes multiple layer-existence checks that all return 404, which looks exactly like a scan.
If this bites you, create an override. Save this as a custom scenario in CrowdSec’s hub or adjust your profiles to whitelist specific paths (like /v2/ for container registries). More on tuning in Part 6.
Part 3: Proxmox Host Bouncer (nftables)
The Proxmox host is the most critical thing to protect. If someone gets into your hypervisor, they own everything. The nftables bouncer drops traffic from bad IPs at the kernel level before any service sees it.
Install directly on the PVE host:
# Add CrowdSec repo
curl -s https://packagecloud.io/install/repositories/crowdsec/crowdsec/script.deb.sh | sudo bash
# Install the nftables bouncer
apt install crowdsec-firewall-bouncer-nftables
Now configure it to talk to the Docker LAPI instead of localhost. Edit /etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml:
api_url: http://192.168.1.50:8080/
api_key: replace-with-a-long-random-string
mode: nftables
Replace the IP with your Docker host’s LAN address. The API key is the BOUNCER_KEY_proxmox value from your Docker compose.
Enable and start:
systemctl enable crowdsec-firewall-bouncer
systemctl start crowdsec-firewall-bouncer
Does this conflict with Proxmox’s own firewall?
Proxmox uses nftables for its own firewall rules. The CrowdSec bouncer creates separate tables (crowdsec and crowdsec6) with priority -10, which means they’re evaluated before other rules. It won’t step on Proxmox’s toes.
You can verify:
nft list tables
# Should show Proxmox tables AND crowdsec / crowdsec6
Test it works
From a different machine, simulate bad behavior. Try SSH with wrong credentials a few times. Then check:
docker exec crowdsec cscli decisions list
You should see the offending IP. Now from the Proxmox host:
nft list set ip crowdsec crowdsec-blacklists | head -20
If the IP appears in the blacklist, the bouncer is working. Any traffic from that IP to the Proxmox host gets dropped.
What if you lock yourself out?
Before you go all-in, whitelist your LAN and your own IPs. From the Docker host:
docker exec crowdsec cscli decisions add --ip 192.168.1.0/24 --duration 99999h --type whitelist
docker exec crowdsec cscli decisions add --ip <your-public-ip> --duration 99999h --type whitelist
Whitelist decisions propagate to all bouncers automatically.
Part 4: MikroTik Router Bouncer
The cs-routeros-bouncer by jmrplens is solid. It speaks the RouterOS API directly, manages address lists, creates firewall rules on start, and removes them on shutdown. No janky scripts pulling CSV exports.
RouterOS setup
First, create a dedicated API user on your MikroTik. SSH in:
/user group add name=crowdsec policy=read,write,api,sensitive,!ftp,!local,!ssh,!reboot,!policy,!test,!password,!sniff,!romon,!rest-api
/user add name=crowdsec group=crowdsec password=YOUR_SECURE_PASSWORD
Make sure the API is enabled: IP → Services, enable api (port 8728) or api-ssl (port 8729).
Deploy the bouncer
I run this on the same Docker VM as the CrowdSec engine. It’s lightweight. Add to your docker-compose.yml:
cs-routeros-bouncer:
image: ghcr.io/jmrplens/cs-routeros-bouncer:latest
container_name: cs-routeros-bouncer
restart: unless-stopped
environment:
CROWDSEC_URL: "http://crowdsec:8080/"
CROWDSEC_BOUNCER_API_KEY: "replace-with-mikrotik-key"
MIKROTIK_HOST: "192.168.0.1:8728"
MIKROTIK_USER: "crowdsec"
MIKROTIK_PASS: "YOUR_SECURE_PASSWORD"
FIREWALL_RAW_ENABLED: "false"
FIREWALL_RAW_ENABLED: false disables raw/prerouting rules. I only want filter rules for the forward chain. Raw rules in prerouting block traffic to the router itself, which is fine if you want that. Your call.
Bring it up:
docker compose up -d cs-routeros-bouncer
Check the logs:
docker logs cs-routeros-bouncer
You should see it connecting to the LAPI, pulling decisions, and pushing them to the MikroTik.
On the MikroTik, check that the address lists and firewall rules were created:
/ip firewall address-list print where list~"crowdsec"
/ip firewall filter print where comment~"crowdsec"
The bouncer creates two chains: one for input (traffic to the router) and one for forward (traffic passing through). The address lists are populated with IPs from CrowdSec decisions. When a decision expires, the IP is removed from the list automatically.
Part 5: Collections and Acquisition
Collections are bundles of parsers and scenarios. They tell CrowdSec what to look for and how to interpret it.
The ones I loaded so far:
crowdsecurity/linux: Syslog parsing, SSH brute force detection, sudo failure detectioncrowdsecurity/sshd: Deeper SSH-specific scenarioscrowdsecurity/traefik: Traefik access log parsing, HTTP probe detection, bad crawler detectioncrowdsecurity/appsec-virtual-patching: WAF rules for known CVE exploitation attemptscrowdsecurity/appsec-generic-rules: Generic attack detection (SQLi, XSS, path traversal)
You can browse available collections:
docker exec crowdsec cscli collections list -a
Install more as needed:
docker exec crowdsec cscli collections install crowdsecurity/proxmox
There’s no official Proxmox collection yet, but you can add custom acquisition for Proxmox logs. Mount the PVE logs to your acquisition.yaml if you want to monitor Proxmox web UI auth attempts (they go through /var/log/syslog on the PVE host).
What CrowdSec actually watches
By default with the linux and sshd collections, it catches:
- SSH brute force (multiple failed auths from one IP)
- HTTP scans (404 floods, path traversal attempts, SQL injection probes)
- DNS amplification participation (if you run a DNS server)
- Aggressive crawlers and known bad user agents (via Traefik logs)
- SQL injection, XSS, and path traversal attempts (via AppSec WAF)
- CVE exploitation attempts (via AppSec virtual patching)
Each scenario has thresholds. For SSH brute force, the default is 5 failed attempts in a short window triggers a ban. You can tune these.
Part 6: Scenario Tuning and Whitelists
Don’t get yourself banned
If you fat-finger your SSH password a few times from a legitimate IP, CrowdSec will ban you. That’s why whitelists matter.
Add permanent whitelists:
# Your LAN
docker exec crowdsec cscli decisions add --ip 192.168.1.0/24 --type whitelist -d 99999h
# Your static WAN IP (if you have one)
docker exec crowdsec cscli decisions add --ip 203.0.113.5 --type whitelist -d 99999h
# Your VPN subnet
docker exec crowdsec cscli decisions add --ip 10.8.0.0/24 --type whitelist -d 99999h
These don’t expire. Whitelisted IPs can do anything without triggering a ban. Be careful with this, obviously.
Tuning scenarios
The default scenarios are reasonable, but you might want to adjust thresholds. Create /opt/crowdsec/config/profiles.yaml:
name: default_ip_remediation
filters:
- Alert.Remediation == true && Alert.GetScope() == "Ip"
decisions:
- type: ban
duration: 4h
on_success: break
---
name: aggressive_ban
filters:
- Alert.Remediation == true && Alert.GetScope() == "Ip"
- Alert.GetScenario() in ["crowdsecurity/ssh-bf", "crowdsecurity/ssh-bf-enum"]
decisions:
- type: ban
duration: 24h
on_success: break
This keeps the default ban at 4 hours but extends SSH brute force bans to 24 hours. The filter matching is order-sensitive. First match wins.
The community blocklist tradeoff
CAPI (Central API) is CrowdSec’s community threat intel network. You share anonymized alerts about attacks hitting your systems. In return, you get a community blocklist of IPs that have attacked other CrowdSec instances.
Why you want it:
- You block IPs before they even touch your services
- Protection against known scanners, botnets, and attack infrastructure
- It’s genuinely good intel
The tradeoff:
- The community blocklist can be aggressive. Legitimate IPs sometimes end up on it
- You’re sharing data about what attacks you receive (anonymized, but still)
- CAPI requires enrollment through the CrowdSec console (free, but needs an account)
To enroll:
docker exec crowdsec cscli console enroll <your-enroll-token>
You get the token from app.crowdsec.net after creating an account.
If you’re running critical services where false positives would be painful, I’d recommend:
- Start without CAPI. Let the local engine build up decisions for a week or two.
- Whitelist everything you absolutely need.
- Then enroll in CAPI and monitor. If you see false positives, you can exclude specific scenarios from CAPI decisions in your profiles.yaml.
Monitoring
Check what’s happening:
# Active decisions
docker exec crowdsec cscli decisions list
# Recent alerts
docker exec crowdsec cscli alerts list
# Bouncer status
docker exec crowdsec cscli bouncers list
# Metrics (decisions by origin, type, etc.)
docker exec crowdsec cscli metrics
The cs-routeros-bouncer also exposes Prometheus metrics on port 2112 if you’re into that.
Maintenance
The CrowdSec image updates frequently. I pull new images once a month:
cd /opt/crowdsec
docker compose pull
docker compose up -d
The nftables bouncer on Proxmox gets updated with apt update && apt upgrade.
The MikroTik bouncer image updates too:
docker compose pull cs-routeros-bouncer
docker compose up -d cs-routeros-bouncer
What this gets you
After setting this up, here’s what changes:
- Someone brute-forcing SSH on your Proxmox host gets blocked at the nftables level after 5 failures. They can’t even establish a TCP connection anymore.
- Web attacks (SQLi probes, path traversal, CVE exploits) get caught by the Traefik bouncer’s AppSec engine before reaching your apps. The Attacker gets a 403 and their IP gets banned.
- The same malicious IP is shared across all bouncers. If Traefik bans them for HTTP probing, the MikroTik and Proxmox bouncers block them too, network-wide.
- If they’re in the community blocklist, they never get a chance. Blocked on sight at every bouncer.
- All of this happens automatically. No manual log checking. No updating blocklists by hand.
It’s not a magic bullet. You still need to keep services updated, close unnecessary ports, and use strong passwords. But CrowdSec catches the stuff you can’t watch manually, and it does it while you sleep.
Let me know if I missed anything.