Skip to content
Serverküche
Search

Loading search … (only available on the published site).

Security Difficulty: Advanced

CrowdSec: modern, collaborative intrusion prevention

Set up CrowdSec with the Traefik bouncer: detect attacks from the access logs, block attackers with a 403 and benefit from the community blocklist.

· 17 min read ·Duration: approx. 45 minutes
Table of contents

Fail2ban blocks brute force on your SSH access – but what about the scanners that probe your web apps for holes all day long? CrowdSec closes exactly this gap: it detects attacks in Traefik’s access logs and bans the attackers before they even reach your applications.

What are we building?

By the end, a CrowdSec agent runs alongside your Traefik, reading along with the access logs, detecting suspicious behavior based on scenarios (port scans, path probing, known CVE exploitation, login brute force) and putting attacker IPs on a block list. A bouncer – here as a Traefik plugin – enforces these decisions: a banned IP only gets 403 Forbidden at every one of your apps. Tested with CrowdSec v1.7.8, the Traefik bouncer plugin v1.7.1 and Traefik v3.7.

This is how the parts interlock:

  • Agent + Local API (LAPI): reads the logs, evaluates them against the scenarios and manages the active bans (“decisions”). That’s the container we set up.
  • Bouncer: asks the LAPI on every request “is this IP banned?” and blocks if yes. The Traefik bouncer runs as a plugin directly in the reverse proxy – no additional container.
  • Community blocklist: CrowdSec reports (anonymized) detected attacks to the central network and gets a blocklist fed by all users back in return. IPs that were already flagged as malicious elsewhere are then banned at your place from the start. That’s the “collaborative” in the title.

CrowdSec instead of fail2ban – or in addition?

CrowdSec doesn’t replace fail2ban, it complements it. fail2ban is unbeatably simple for a single service on the host (SSH). CrowdSec plays to its strength with HTTP apps behind the reverse proxy: one bouncer protects all apps at once, the scenarios are maintained and current, and you get the community blocklist for free. The clean split: fail2ban stays on SSH, CrowdSec takes over the web layer. Both may run in parallel.

Prerequisites

  • A running reverse proxy with Traefik (the proxy network and the resolver le come from there) with at least one app behind it.
  • Fail2ban for SSH protection – CrowdSec handles the HTTP layer here, not SSH.
  • Basic knowledge of Docker Compose and a hardened server with an active firewall.
🍳 Recommendation Ad

VPS 2000 G12

8 vCores · 16 GB RAM · 512 GB NVMe

from €19.24/month

CrowdSec runs with a few hundred MB alongside your existing app stack – the VPS 2000 has enough reserve for that.

Go to netcup →

💶 5 € voucher for new netcup customers:36nc17844976032 (new customers only, no domains)

Step by step

Step 1: Enable Traefik’s access logs

CrowdSec can only detect what it sees – and it sees attacks via Traefik’s access logs. They’re off by default. Open the compose.yaml of your Traefik (from the Traefik tutorial, folder ~/traefik) and add these three lines to the command block:

YAML
      - "--accesslog=true"
      - "--accesslog.filepath=/var/log/traefik/access.log"
      - "--accesslog.format=json"

The JSON format is important – the CrowdSec parsers expect it that way. So that both Traefik (writing) and CrowdSec (reading) can reach the same file, you put it on a fixed host path. Add to Traefik’s volumes block:

YAML
      - /var/log/traefik:/var/log/traefik

Create the directory on the host and restart Traefik:

Terminal
sudo mkdir -p /var/log/traefik
cd ~/traefik && docker compose up -d

Call one of your apps once and check that lines arrive:

Terminal
tail -n 2 /var/log/traefik/access.log

You should see JSON lines with "ClientHost", "RequestPath" and "DownstreamStatus". If the file stays empty, the path in the volume is wrong, or simply no request has come in yet.

Don’t skip this check: in step 2, CrowdSec only picks up files that already exist at startup. If the access.log is missing at that moment, it reports No matching files for pattern /var/log/traefik/access.log while booting and won’t read the file later either – then only a restart of the container helps.

Step 2: Set up the CrowdSec container

CrowdSec gets its own folder. First generate a bouncer key – the Traefik bouncer later authenticates with it at the LAPI:

Terminal
mkdir -p ~/crowdsec && cd ~/crowdsec
openssl rand -hex 32

Copy the output; it’s used in two places shortly. Create the acquisition – it tells CrowdSec which file with which log type to read:

YAML
# ~/crowdsec/acquis.yaml
filenames:
  - /var/log/traefik/access.log
labels:
  type: traefik

Now the compose.yaml. Replace YOUR_BOUNCER_KEY with the generated key:

YAML
services:
  crowdsec:
    image: crowdsecurity/crowdsec:v1.7.8
    container_name: crowdsec
    environment:
      COLLECTIONS: "crowdsecurity/traefik"
      BOUNCER_KEY_TRAEFIK: "YOUR_BOUNCER_KEY"
      GID: "1000"
    volumes:
      - ./acquis.yaml:/etc/crowdsec/acquis.d/traefik.yaml:ro
      - /var/log/traefik:/var/log/traefik:ro
      - crowdsec-db:/var/lib/crowdsec/data/
      - crowdsec-config:/etc/crowdsec/
    networks: [proxy]
    restart: unless-stopped

volumes:
  crowdsec-db:
  crowdsec-config:

networks:
  proxy:
    external: true

What matters here:

  • COLLECTIONS: crowdsecurity/traefik installs the matching collection of parsers and scenarios for Traefik logs on the first start (including the HTTP base scenarios).
  • BOUNCER_KEY_TRAEFIK registers a bouncer named TRAEFIK with your key right at startup – that saves the detour via cscli bouncers add.
  • GID: "1000" runs the CrowdSec process under this group. 1000 is the usual default for the first user; if Traefik runs as root, the access.log is usually world-readable and the GID doesn’t matter anyway. If it’s set more tightly, check with ls -l /var/log/traefik/access.log and enter the GID of the file’s group.
  • The log is mounted read-only (:ro). CrowdSec is on the proxy network so the Traefik bouncer can reach the LAPI at crowdsec:8080 later.

Start the container and check that everything takes effect:

Terminal
docker compose up -d
docker exec crowdsec cscli collections list

The Traefik collection and the HTTP scenarios should appear as enabled:

Ausgabe
 Name                                 📦 Status   Version
 crowdsecurity/base-http-scenarios    ✔️  enabled  1.4
 crowdsecurity/http-cve               ✔️  enabled  3.0
 crowdsecurity/linux                  ✔️  enabled  0.4
 crowdsecurity/sshd                   ✔️  enabled  0.9
 crowdsecurity/traefik                ✔️  enabled  0.2
 crowdsecurity/whitelist-good-actors  ✔️  enabled  0.4

You didn’t ask for linux, sshd and whitelist-good-actors – the image brings them along by default. sshd runs empty as long as you don’t add an SSH log to the acquisition (that’s still fail2ban’s job here); whitelist-good-actors prevents well-known good bots such as the Googlebot from being banned.

Check that the community blocklist is active – it costs nothing and is half the benefit of CrowdSec:

Terminal
docker exec crowdsec cscli capi status
Ausgabe
You can successfully interact with Central API (CAPI)
Sharing signals is enabled
Pulling community blocklist is enabled
Pulling blocklists from the console is enabled

Pulling community blocklist is enabled confirms that you receive the collective block list of the CrowdSec community – attackers that stood out elsewhere are already banned at your place before they even try.

The community blocklist starts out empty

Don’t expect a filled block list after the first start. The freshly registered container does fetch the list right away, but gets nothing yet – the log then literally says capi/community-blocklist : received 0 new entries (expected if you just installed crowdsec), and cscli decisions list reports No active decisions. CrowdSec pulls the list periodically in the background after that, so the first entries show up with a delay. That’s normal and not a configuration error.

Step 3: Add the Traefik bouncer

Now the part that enforces the bans. The bouncer is a Traefik plugin and consists of two additions to your Traefik: load the plugin and define a middleware that acts on all apps.

Load the plugin by adding to the command block of ~/traefik/compose.yaml:

YAML
      - "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
      - "--experimental.plugins.bouncer.version=v1.7.1"
      - "--entrypoints.websecure.http.middlewares=crowdsec@file"

The last line attaches the bouncer middleware globally to the websecure entrypoint – so every HTTPS request to every app runs through the bouncer, without you touching each app individually.

Don't overwrite existing entrypoint middlewares

--entrypoints.websecure.http.middlewares is single-valued: if you’ve already set middlewares on this entrypoint in the Traefik setup (e.g. security headers), a second flag overwrites the existing value – then your previous middlewares silently drop out. So don’t set a second flag, but extend the existing value comma-separated, e.g. --entrypoints.websecure.http.middlewares=sec-headers@docker,crowdsec@file. Only if no middleware is attached to this entrypoint so far do you add the flag anew as above.

The @file suffix tells Traefik where the middleware comes from: from the file provider, i.e. from a YAML file instead of container labels. The Traefik setup from the Traefik tutorial gets by without that provider – so you switch it on now with two more lines in the command block:

YAML
      - "--providers.file.directory=/dynamic"
      - "--providers.file.watch=true"

And mount the directory it reads from into the volumes block:

YAML
      - ./dynamic:/dynamic:ro

--providers.file.watch=true pays off especially here: with it, Traefik picks up changes to the middleware file without a restart – in our test a changed bouncer configuration took effect after a few seconds.

No second file-provider flag

If your Traefik already uses the file provider – for instance because you set it up in Hardening Traefik (Tutorial expected in October) – don’t add a second flag: Traefik does not allow filename and directory at the same time. If your provider points to a directory, the file below simply goes in there; if it points to a single filename file, you write the http.middlewares block into that one.

Create the middleware (path adapted to your file provider):

YAML
# ~/traefik/dynamic/crowdsec.yaml
http:
  middlewares:
    crowdsec:
      plugin:
        bouncer:
          enabled: true
          crowdsecMode: stream
          crowdsecLapiScheme: http
          crowdsecLapiHost: crowdsec:8080
          crowdsecLapiKey: "YOUR_BOUNCER_KEY"

crowdsecLapiKey is the same key as in step 2 – the bouncer identifies itself with it at the LAPI. With crowdsecMode: stream the bouncer pulls the block list completely in advance at a fixed interval and answers every request from the local cache – the mode recommended by the plugin, because it doesn’t load the LAPI on every request.

Restart Traefik so it downloads the plugin and loads the middleware:

Terminal
cd ~/traefik && docker compose up -d
docker compose logs -f traefik

The log must show no ERR about the plugin or the middleware (Traefik v3 logs only errors at the default level – a quiet output is the good sign). Check from the CrowdSec side that the bouncer has connected:

Terminal
docker exec crowdsec cscli bouncers list
Ausgabe
 Name     IP Address  Valid  Last API pull         Type                             Version  Auth Type
 TRAEFIK  172.18.0.4  ✔️      2026-08-06T23:37:45Z  Crowdsec-Bouncer-Traefik-Plugin  v1.7.1   api-key

A set Last API pull proves: the plugin talks to the LAPI. The Version column reports the plugin version back – a convenient way to check after an update that Traefik really loaded the new build and not a cached old one.

Start CrowdSec first, then restart Traefik

If the bouncer can’t reach the LAPI when Traefik starts, it shuts the door and answers every request with 403 – until the next pull succeeds. In our test that window lasted just under a minute; during that time the Traefik log says crowdsecQuery:unreachable. If the crowdsec container is already running, on the other hand, a Traefik restart goes through without a single failed request. So stick to the order of this tutorial: CrowdSec first (step 2), Traefik after.

Step 4: Arm it and test the ban

Call one of your apps – it should respond perfectly normally:

Terminal
curl -s -o /dev/null -w "%{http_code}\n" https://YOUR_APP.YOUR_DOMAIN/
Ausgabe
200

Now you test-ban an IP by hand and see whether the bouncer locks it out. Take your own public IP (curl -s https://ifconfig.me):

Terminal
docker exec crowdsec cscli decisions add --ip YOUR_TEST_IP --duration 5m --reason "test"

New bans don't take effect immediately

The bouncer fetches the decisions in the background and caches them – it can take up to ~60 seconds for a fresh ban to take effect (in our test: 41 seconds between decisions add and the first 403). That’s no bug but intended (less load on the LAPI). In stream mode the delay comes from the pull interval (updateIntervalSeconds, default 60 seconds), in live mode from the per-IP cache of each decision (defaultDecisionSeconds, default 60 seconds). In real operation this is irrelevant; when testing you have to wait briefly. Whoever wants to reduce the reaction time lowers the respective value – i.e. updateIntervalSeconds (stream) or defaultDecisionSeconds (live) – in the middleware, instead of switching modes.

After the wait, the banned IP only gets:

Terminal
curl -s -o /dev/null -w "%{http_code}\n" https://YOUR_APP.YOUR_DOMAIN/
Ausgabe
403

Lift the test ban again:

Terminal
docker exec crowdsec cscli decisions delete --ip YOUR_TEST_IP

Step 5: Let it detect real attacks

The actual point: CrowdSec bans on its own, without you writing rules. As soon as a scanner probes your domain for paths (many 404 in a short time), the scenario crowdsecurity/http-probing kicks in. This is what an automatically detected ban looks like:

Terminal
docker exec crowdsec cscli alerts list
Ausgabe
 ID  value              reason                             country  as                     decisions
 3   Ip:162.142.125.29  crowdsecurity/http-probing         US       398324 CENSYS-ARIN-01  ban:1
 2   Ip:162.142.125.29  crowdsecurity/http-wordpress-scan  US       398324 CENSYS-ARIN-01  ban:1
Terminal
docker exec crowdsec cscli decisions list
Ausgabe
 ID  Source    Scope:Value        Reason                      Action  Country  AS                     Events
 3   crowdsec  Ip:162.142.125.29  crowdsecurity/http-probing  ban     US       398324 CENSYS-ARIN-01  11

Two things are instructive here. First, CrowdSec automatically enriches the ban with country and provider (AS) – this IP belongs to the address range of the internet-wide scanner Censys, and it triggered two scenarios at once: general path probing and the WordPress variant of it. Second, an IP can have several alerts but only one active decision – cscli decisions list reports the second one as duplicated entries skipped.

Besides the self-detected attacks, bans with the source lists:crowdsecurity/... appear here over time – that’s the community blocklist: IPs that already stood out negatively worldwide, you block before they hit you the first time.

Why local tests don't trigger anything

If you want to reproduce this yourself: requests from your own network never produce a ban. CrowdSec ships the parser crowdsecurity/whitelists, which discards all private IP ranges (RFC 1918) – cscli metrics then shows them under Lines whitelisted. So a test from localhost or from the Docker network always comes to nothing; only traffic from a public IP reaches the scenarios at all.

Whoever prefers to see the bans graphically instead of in cscli tables enrolls the instance for free in the CrowdSec console (the enroll command is in “Maintenance & backups”). There the same decisions appear presented nicely:

The CrowdSec console lists the active bans as a table: several IP addresses with the action “ban”, an expiry time and the triggering scenario http-probing, each “via crowdsec”
The CrowdSec console (optional, free): the same decisions as in cscli, just graphical

The HTTP scenarios installed with the Traefik collection cover what real scanners try all day: probing non-existent paths (http-probing), searching for sensitive files and backdoors, path traversal and SQL injection/XSS probes, suspicious user agents of known scanners, as well as the exploitation of known CVEs (from the collection crowdsecurity/http-cve). Which scenarios are active is shown by docker exec crowdsec cscli scenarios list.

One notch sharper: AppSec (WAF)

Beyond pure IP blocking, CrowdSec can work as a web application firewall and detect malicious requests directly by their content (virtual patching). The Traefik plugin supports this AppSec mode (from plugin 1.2.0 and CrowdSec 1.6.0). For getting started, the scenario-based blocking shown here is enough; AppSec is the worthwhile next expansion step when a particularly exposed app needs additional protection.

Whether CrowdSec reads your logs at all is shown by the metrics overview. A bare cscli metrics now dumps a dozen tables – so ask for the acquisition specifically:

Terminal
docker exec crowdsec cscli metrics show acquisition
Ausgabe
| Source                           | Lines read | Lines parsed | Lines unparsed | Lines poured to bucket | Lines whitelisted |
| file:/var/log/traefik/access.log | 40         | 40           | -              | -                      | 40                |

Lines read and Lines parsed being equally high means CrowdSec reads the file and understands the JSON. That Lines whitelisted also sits at 40 here is down to the test – the requests came from the Docker network (see the box above). If the line is missing entirely, no log is arriving: cscli omits empty tables, so you don’t see Lines read: 0 but no acquisition line at all – then back to step 1.

Step 6: Don’t lock yourself out

A false alarm against your own maintenance IP is annoying. Enter your fixed IP as trusted in the middleware – the bouncer then doesn’t even check it. Add to ~/traefik/dynamic/crowdsec.yaml under bouncer::

YAML
          clientTrustedIPs:
            - "YOUR_FIXED_IP/32"

With --providers.file.watch=true (step 3) Traefik picks the change up without a restart. Verified: an IP entered as trusted keeps getting 200 even with an active ban – the bouncer doesn’t even query the block list for it.

If you did lock yourself out, one command lifts the ban immediately – and in an emergency you can always reach the server via the VNC console in the SCP:

Terminal
docker exec crowdsec cscli decisions delete --ip YOUR_IP   # one IP
docker exec crowdsec cscli decisions delete --all           # all bans

When things go wrong

A freshly set ban doesn’t take effect, the IP keeps getting 200. The bouncer caches the decisions and, in stream mode, pulls new ones only after the pull interval (up to ~60 seconds). Wait briefly. Whoever wants to reduce the reaction time lowers updateIntervalSeconds in the middleware (step 3).

cscli metrics show acquisition shows no line for the log file. CrowdSec isn’t reading the logs – and because cscli omits empty tables, the line is missing entirely instead of showing Lines read: 0. Most common cause: the container started before the access.log existed; then docker logs crowdsec contains the line No matching files for pattern /var/log/traefik/access.log, and CrowdSec won’t pick the file up by itself later – cd ~/crowdsec && docker compose restart fixes it. Otherwise check that Traefik really writes to /var/log/traefik/access.log (step 1), that both containers mount the same host path, and that the acquis.yaml names exactly this path.

After restarting Traefik every app returns 403 – including for you. The bouncer couldn’t reach the LAPI at startup and then blocks in doubt instead of letting traffic through unprotected. The Traefik log says crowdsecQuery:unreachable with the URL http://crowdsec:8080/v1/decisions/stream. Check with docker ps that the crowdsec container is Up – as soon as the next pull succeeds (up to ~60 seconds), requests go through again on their own. That’s safe, but makes CrowdSec a critical component: if the container isn’t running, your site is affected. restart: unless-stopped (set above) brings it back automatically after a server reboot.

CrowdSec bans lots of harmless or no real visitors – the detected IP is always your CDN’s. If a CDN like Cloudflare sits in front of Traefik, Traefik only sees its IP. Then have the real client IP read from the X-Forwarded-For header: fill forwardedHeadersTrustedIPs in the middleware with the CDN networks – otherwise you end up banning the CDN. Directly at netcup (without a CDN) there’s nothing to do here.

docker compose up -d on Traefik reports a plugin error. Traefik downloads the plugin from the net on startup. Check that the server may go out (the netcup firewall blocks nothing outbound, UFW likewise) and that modulename and version are exactly right.

Maintenance & backups

  • Keep scenarios current. CrowdSec’s strength is maintained detection rules – pull them regularly: docker exec crowdsec cscli hub update && docker exec crowdsec cscli hub upgrade. You bump the image (crowdsecurity/crowdsec) and the plugin version (v1.7.1) deliberately; read the release notes on major versions. After a plugin update, the Version column in cscli bouncers list tells you whether Traefik really loaded the new build. CrowdSec develops briskly – a good candidate for regular version checks.

  • Rotate the access log. Traefik’s /var/log/traefik/access.log otherwise grows unbounded. Set up a logrotate rule – mandatorily with copytruncate: this variant copies the file away and empties the original in place, instead of renaming it and creating a new one. Only this way does CrowdSec’s tail keep the file access (with rename/create it would lose the handle and read nothing anymore). Create /etc/logrotate.d/traefik:

    Ausgabe
    /var/log/traefik/access.log {
        daily
        rotate 7
        compress
        missingok
        notifempty
        copytruncate
    }

    After the truncate, CrowdSec reads on perfectly normally from the file that’s growing again – a restart of the container isn’t needed.

  • fail2ban stays. Don’t remove fail2ban – it continues to protect your SSH access at the host level, while CrowdSec covers the web apps. Two tools, two responsibilities.

  • Backups. What you must back up is above all your configuration: the folder ~/crowdsec (compose.yaml, acquis.yaml) and ~/traefik/dynamic/crowdsec.yaml with the bouncer key. Include them in your Restic backup. The block lists themselves are ephemeral and rebuild on their own – no backup needed.

  • Optional: the CrowdSec dashboard. Whoever wants to see the attacks graphically enrolls the instance for free in the CrowdSec console (docker exec crowdsec cscli console enroll YOUR_ENROLL_KEY). The community blocklist runs without enrollment too.

  • Integrate it into your monitoring. cscli metrics gives you numbers for the terminal; for continuous observation, CrowdSec provides a Prometheus endpoint (enable it in the config under prometheus:) that you can hang on your Grafana stack. Then you see bans by origin and scenario at a glance:

    A Grafana dashboard with CrowdSec metrics: around 15,000 active bans total, broken down by origin (community blocklist vs. locally detected) and by scenario like ssh:bruteforce, generic:scan and http-probing
    CrowdSec in Grafana: active bans by origin and scenario, fed from the Prometheus endpoint

  • Honest about the effort: After setup, CrowdSec runs largely on its own. Plan a hub upgrade and a look at cscli metrics and the container version once a month.

Last updated: Aug 20, 2026

You might also like