CrowdSec with Traefik
This page is for operators who already run (or want to run) Traefik with a CrowdSec bouncer and want PicPeak behind it. It describes a deployment contract, not a PicPeak feature: PicPeak itself does not talk to CrowdSec, and an install that does not opt in is unchanged.
New to CrowdSec, or no Traefik? Start with CrowdSec (Advanced), which covers the host firewall blocker and NPMplus.
What runs where
Client
│ HTTPS
▼
Traefik ── CrowdSec bouncer middleware (crowdsec@file)
│ asks LAPI: is this client IP banned? → 403 if yes
│ HTTP, X-Forwarded-For: <client>
▼
PicPeak frontend nginx (container port 80, not published)
│ X-Forwarded-For: <client>, <Traefik>
▼
PicPeak backend (Express, TRUST_PROXY=2)
req.ip = <client> → rate limits, lockouts, audit logs
Traefik access log ──▶ CrowdSec Security Engine (parsers, scenarios) ──▶ LAPI ──▶ bouncer decisions| Part | Owned by | Notes |
|---|---|---|
| CrowdSec Security Engine, LAPI, its database | your infrastructure | Can be a central LAPI shared by many services |
| Bouncer API key | your infrastructure | Never in PicPeak’s .env or Compose files |
Traefik, the bouncer plugin, the crowdsec middleware | your infrastructure | Defined once in Traefik’s dynamic configuration |
| Router labels that attach PicPeak to the middleware | PicPeak overlay file | docker-compose.crowdsec-traefik.example.yml |
CrowdSec only adds IP-based decisions in front of PicPeak. PicPeak’s own login lockout, rate limiting and upload quotas stay on; do not switch them off.
Prerequisites
- PicPeak installed with
docker-compose.production.yml(Compose 2.24 or newer for the overlay’s!reset). - Traefik v3 on the same Docker host, attached to an external Docker network (here called
traefik), with awebsecureentrypoint and a certificate resolver. - A CrowdSec Security Engine with LAPI that Traefik can reach, and a bouncer key from
cscli bouncers add traefik-bouncer.
Load the bouncer plugin in Traefik
This uses the community CrowdSec Bouncer Traefik plugin . In Traefik’s static configuration (traefik.yml), pin a current release:
experimental:
plugins:
bouncer:
moduleName: github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
version: vX.Y.Z # the plugin's latest releaseDefine the middleware
In Traefik’s dynamic configuration (file provider, e.g. /etc/traefik/dynamic/crowdsec.yml). The @file in crowdsec@file refers to this provider.
http:
middlewares:
crowdsec:
plugin:
bouncer:
enabled: true
crowdsecMode: stream # keeps a local copy of decisions; no LAPI call per request
crowdsecLapiScheme: http
crowdsecLapiHost: crowdsec:8080
crowdsecLapiKeyFile: /run/secrets/crowdsec_bouncer_key
crowdsecAppsecEnabled: false # IP decisions only, see belowKeep the key in a file or secret that only Traefik can read. Check the option names against the plugin’s README for the version you pinned.
Attach PicPeak’s router
From the PicPeak directory:
cp docker-compose.crowdsec-traefik.example.yml docker-compose.crowdsec-traefik.yml
echo 'PICPEAK_DOMAIN=photos.example.com' >> .env
echo 'COMPOSE_FILE=docker-compose.production.yml:docker-compose.crowdsec-traefik.yml' >> .env
docker compose up -dCOMPOSE_FILE makes every later docker compose command, updates included, use both files. If you leave it out, pass both -f arguments every time: an update run with docker-compose.production.yml alone drops the Traefik labels and publishes the frontend port again, so CrowdSec is no longer in front. The in-app updater reuses the files the stack was started with.
The overlay:
- adds the
traefiknetwork and the router labels to thefrontendservice, withtraefik.http.routers.picpeak.middlewares=crowdsec@file; - removes the published frontend port, so Traefik is the only way in;
- sets
TRUST_PROXYon the backend fromTRAEFIK_TRUST_PROXY(default2), andCOOKIE_SECURE=true. It has its own variable because.envusually still holdsTRUST_PROXY=1from the default install, which is wrong behind Traefik.
TRAEFIK_NETWORK, CROWDSEC_MIDDLEWARE, TRAEFIK_ENTRYPOINT and TRAEFIK_CERTRESOLVER in .env override the defaults (traefik, crowdsec@file, websecure, letsencrypt).
Routing everything to the frontend nginx is deliberate: it already forwards /api, /photos, /thumbnails and /uploads to the backend, with request buffering off and a 1 GB body limit for uploads, and one router means one place where the middleware has to be attached.
Feed CrowdSec the Traefik access log
See Log acquisition and privacy below for the Traefik side. On the CrowdSec side:
cscli collections install crowdsecurity/traefik# /etc/crowdsec/acquis.d/traefik.yaml
filenames:
- /var/log/traefik/access.log
labels:
type: traefikThen remove the crawler scenario, which bans normal gallery guests (why):
cscli scenarios remove crowdsecurity/http-crawl-non_statics --forceCrowdSec reads acquisition files and scenarios only at start, so reload it to apply both changes: sudo systemctl reload crowdsec on a host install, docker restart crowdsec (or your container’s name) for the container. Until then the Traefik log is not read and the crawler scenario keeps banning.
Verify
Run the checklist below before you rely on it.
Client IP: one answer for every layer
Traefik, CrowdSec, nginx and Express must all agree on the client address, and none of them may accept an address the client made up.
| Layer | What it does | Configuration |
|---|---|---|
| Traefik entrypoint | Without forwardedHeaders.trustedIPs (the default), Traefik drops incoming X-Forwarded-* headers from the client and sets X-Forwarded-For to the connecting address. | Leave unset when clients connect to Traefik directly. |
| Bouncer plugin | Checks the connecting address unless the request came from an IP in forwardedHeadersTrustedIPs. | Leave empty when clients connect to Traefik directly. |
| CrowdSec | Reads ClientHost from the Traefik access log, which is the connecting address. | Nothing to set. |
| PicPeak nginx | Appends the address it saw (Traefik’s) with proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for. It has no set_real_ip_from, so it never rewrites $remote_addr from a header. It also sends X-Real-IP: $remote_addr (Traefik’s address); the backend does not use X-Real-IP. | Nothing to set. |
| Express | Walks X-Forwarded-For from the right and stops after TRUST_PROXY trusted hops. req.ip is what rate limits, lockouts and audit logs use. | TRUST_PROXY=2 |
TRUST_PROXY is parsed by the backend as follows: unset or empty keeps the legacy loopback, linklocal, uniquelocal trust (and logs a warning in production); false or 0 trusts no forwarding header; true trusts every hop; a number is a hop count; anything else (a subnet name, a CIDR, a comma list) is passed to Express unchanged. Use the hop count. Do not use true.
Trust boundary: docker-compose.production.yml still publishes the backend on 127.0.0.1:3001 for local tools. A process on the host that calls that port directly can send its own X-Forwarded-For and, with two trusted hops, pick the address the backend sees. Only run trusted software on the host, or drop that port mapping in your copy of the overlay if nothing uses it.
With TRUST_PROXY=2 the backend takes the entry Traefik wrote and ignores anything to its left. A client sending X-Forwarded-For: 1.2.3.4 through Traefik gets that header dropped at the entrypoint. A client reaching nginx directly could plant one entry, which is why the overlay removes the published frontend port. If you keep a port for LAN access, bind it to loopback (PICPEAK_BIND_ADDRESS=127.0.0.1) instead.
A CDN or load balancer in front of Traefik (e.g. Cloudflare) adds one hop. Set the provider’s address ranges in both places and raise the count:
# traefik.yml
entryPoints:
websecure:
address: ":443"
forwardedHeaders:
trustedIPs:
- "173.245.48.0/20" # ...the provider's published ranges# bouncer middleware
forwardedHeadersTrustedIPs:
- "173.245.48.0/20"and TRAEFIK_TRUST_PROXY=3 in .env (not TRUST_PROXY: the overlay sets the backend’s value from TRAEFIK_TRUST_PROXY), then docker compose up -d to recreate the backend. CrowdSec then needs the real client address in the access log as well; with a CDN in front, ClientHost is the CDN edge. Prefer the CDN’s own bot protection in that layout (Bots and Scanners).
IPv6. If Traefik runs in a Docker bridge network without IPv6 enabled and publishes its ports, Docker’s userland proxy accepts IPv6 connections and forwards them from the bridge gateway address (for example 172.18.0.1). Every IPv6 client then shares one IPv4 address, in Traefik’s log, in CrowdSec and in PicPeak’s rate limits, and a ban on it blocks all of them. Either enable IPv6 for Docker and the traefik network, or run Traefik with network_mode: host. Check by opening the site from an IPv6-only connection and looking at the Traefik access log.
Log acquisition and privacy
CrowdSec should read Traefik’s access log only. It does not need PicPeak’s backend log or nginx log, and it does not need the Docker socket. Do not mount /var/run/docker.sock into CrowdSec to read container logs; a file on a shared volume, mounted read-only into CrowdSec, is enough.
Traefik static configuration:
accessLog:
filePath: /var/log/traefik/access.log
format: json
fields:
defaultMode: keep
names:
ClientUsername: drop
headers:
defaultMode: drop # no Cookie, Authorization, Referer, X-* headers
names:
User-Agent: keep # used by CrowdSec's bad-user-agent scenariosThis keeps cookies, Authorization headers and Referer out of the log. It does not remove the URL: Traefik writes the request path including the query string to RequestPath, and CrowdSec’s HTTP scenarios need the path. Some PicPeak URLs carry secrets:
| URL | Secret |
|---|---|
/gallery/<slug>/<token> | gallery share token |
/gallery/<slug>/client-access?token=… | client access token |
/invite/<token> | admin invitation token |
photo-sales price-check URLs with sig= | signed sale token |
Gallery passwords are sent in request bodies, never in URLs, and Traefik does not log bodies.
Treat the access log as sensitive:
-
Make it readable only by Traefik and CrowdSec (
chmod 640, shared group). -
Keep it short-lived. Traefik does not rotate its own log; rotate with
logrotateand send TraefikUSR1so it reopens the file. A few days are enough for CrowdSec, which reads the log as it is written. -
Do not ship it to a central log store without masking those paths.
-
To keep the most common token URLs out of the log, add a second Traefik router for the
/galleryand/invitepages with access logs switched off (Traefik v3.3 or later; older versions reject the label). It must carry the bouncer middleware itself: Traefik does not pass a router’s middlewares on to another router, and the longer path rule wins, so without it banned addresses reach those paths. CrowdSec no longer sees probes on those paths, but the bouncer still blocks known-bad IPs there.- "traefik.http.routers.picpeak-private.rule=Host(`${PICPEAK_DOMAIN}`) && (PathPrefix(`/gallery`) || PathPrefix(`/invite`))" - "traefik.http.routers.picpeak-private.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}" - "traefik.http.routers.picpeak-private.tls.certresolver=${TRAEFIK_CERTRESOLVER:-letsencrypt}" - "traefik.http.routers.picpeak-private.middlewares=${CROWDSEC_MIDDLEWARE:-crowdsec@file}" - "traefik.http.routers.picpeak-private.observability.accessLogs=false" - "traefik.http.routers.picpeak-private.service=picpeak"This covers page loads only. The API calls those pages make, and other links, still carry tokens in the path and are still logged:
/api/invite/<token>, PicTransfer (/api/public/transfer/<token>,/api/public/transfer-upload/<token>), contract signing (/api/public/contract-signing/invite/<token>), quote and payment-check links. A router cannot strip a token from a path, so treat the log as sensitive either way (the points above), or switch Traefik’s access log off for the PicPeak router entirely and accept that CrowdSec then only sees decisions from elsewhere.
What CrowdSec reports to its network is described under CrowdSec (Advanced) → Privacy.
AppSec stays off
The MVP covers IP decisions only. Keep crowdsecAppsecEnabled: false on the PicPeak middleware. PicPeak streams uploads of up to 1 GB through nginx with request buffering off; AppSec body inspection would buffer or reject them. If you enable AppSec later, use a separate middleware on a router that excludes /api upload routes.
Upload timeout: Traefik v3 stops reading a request after 60 seconds by default (respondingTimeouts.readTimeout). Gallery and admin uploads of large files go up in chunks, so each request stays short, but a single large request on a slow uplink can hit that limit. Raise it on the entry point PicPeak uses, for example entryPoints.websecure.transport.respondingTimeouts.readTimeout: 600s.
Docker Swarm
PicPeak ships no Swarm stack file. In a Swarm stack, the same labels go under deploy.labels of the frontend service, and Traefik’s Swarm provider needs the port label:
services:
frontend:
deploy:
labels:
- traefik.enable=true
- traefik.swarm.network=traefik
- traefik.http.routers.picpeak.rule=Host(`photos.example.com`)
- traefik.http.routers.picpeak.entrypoints=websecure
- traefik.http.routers.picpeak.tls.certresolver=letsencrypt
- traefik.http.routers.picpeak.middlewares=crowdsec@file
- traefik.http.services.picpeak.loadbalancer.server.port=80traefik.swarm.network is the Traefik v3 label; Traefik v2 uses traefik.docker.network. Publish Traefik’s ports with mode: host: the Swarm ingress mesh replaces the client address with an internal one, which breaks every IP decision.
Without Traefik
- Bundled frontend nginx only (no proxy of your own): the nginx in the frontend image has no bouncer and PicPeak will not add one. Use the host firewall bouncer as in CrowdSec (Advanced) → On a Linux server, including the
DOCKER-USERchain. KeepTRUST_PROXY=1. - Your own nginx on the host: CrowdSec’s nginx bouncer (
crowdsec-nginx-bouncer, needs nginx with the Lua module) checks each request; or use the firewall bouncer. Read nginx’s access log with thecrowdsecurity/nginxcollection. Forward everything to the frontend port withTRUST_PROXY=2and bind the frontend port to loopback (Reverse Proxy). Do not add$args,$http_cookieor$http_authorizationto that log format. - Caddy, HAProxy, Nginx Proxy Manager: use the matching bouncer or the firewall bouncer; the client-IP and log rules above apply unchanged.
Verification checklist
- Default install unaffected:
docker compose -f docker-compose.production.yml config | grep -c traefikprints0. - Ban is enforced at the edge: from a test machine,
cscli decisions add --ip <test-ip> --duration 5m, thencurl -I https://photos.example.com/from it returns403, and no request from that IP shows up indocker compose logs frontend.cscli decisions delete --ip <test-ip>lifts it. In stream mode allow up to the plugin’s update interval (60 seconds by default). - Normal use works: log in to the admin, open a gallery, download a photo, and upload a video of several hundred MB.
- Spoofing is ignored:
curl -H 'X-Forwarded-For: 198.51.100.1' -H 'X-Real-IP: 198.51.100.1' https://photos.example.com/api/health. The Traefik log shows your real address asClientHost, and PicPeak’s activity/security log for a failed login made that way shows your real address too. - Same IP everywhere, IPv4 and IPv6: for one failed admin login, compare Traefik’s
ClientHost,cscli alerts list(after a few attempts) and PicPeak’s security log. - PicPeak limits still apply: five wrong admin passwords still trigger PicPeak’s lockout.
- Log is clean:
grep -E -i 'cookie|authorization|bearer' /var/log/traefik/access.logfinds nothing. - No AppSec: the middleware has
crowdsecAppsecEnabled: false.
Upgrades and rollback
PicPeak updates do not touch the overlay or Traefik. With COMPOSE_FILE set, docker compose pull && docker compose up -d keeps both files; without it, pass the same two -f arguments. To roll back, remove the overlay from COMPOSE_FILE (or drop the second -f) and run docker compose up -d: the backend goes back to TRUST_PROXY from .env (default 1) and the frontend port is published again. No PicPeak code or primary Compose file changes either way.
If a real visitor is blocked, see If a real visitor gets locked out.