Security · May 13, 2026 · 14 min read

Homelab Intrusion Defense with CrowdSec: Engine in Docker, Bouncers on Proxmox and MikroTik

Run CrowdSec in Docker as a central detection engine, with nftables bouncer on Proxmox and cs-routeros-bouncer on MikroTik. Full setup with collection tuning, whitelisting, and CAPI tradeoffs.

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:

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:

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:

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:

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:

The tradeoff:

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:

  1. Start without CAPI. Let the local engine build up decisions for a week or two.
  2. Whitelist everything you absolutely need.
  3. 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:

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.

← All posts
Category: Security