CrowdSec
CrowdSec reads logs, decides which addresses are misbehaving, and shares those decisions through its Local API (LAPI). CPM turns Caddy into a CrowdSec bouncer with caddy-crowdsec-bouncer: Caddy keeps the LAPI’s decisions in memory and refuses the addresses they name before anything else looks at the request.
The Local API comes from one of two places, chosen under Settings → CrowdSec → Local API:
- Managed (bundled host only): the bundled agent runs CrowdSec in a container beside Caddy and feeds it Caddy’s access log. Nothing to install, no key to copy.
- External: a CrowdSec you run yourself, reading whatever logs you point it at.
Either way, first turn on CrowdSec under Settings → Caddy Build and rebuild Caddy. It is an opt-in module, not in the default image.
Managed
Section titled “Managed”Managed mode needs the crowdsec service in your docker-compose.yml. The release archive
includes it, behind its Compose profile, so it does not run until you turn managed mode on.
-
Open Settings → CrowdSec, switch it on, and choose Managed (bundled host only) as the Local API.
-
Save and apply. The bundled agent starts the
crowdseccontainer. Its first start downloads the image and CrowdSec’s detection rules from the CrowdSec hub, which takes a few minutes; the settings page shows when it is running.
What CPM sets up:
- The container is
crowdsecurity/crowdsec, pinned to a release, behind thecrowdsecCompose profile, the way analytics runs ClickHouse. The agent passes--profile crowdsecitself;docker compose upalone never starts it. Its state lives in thecrowdsec-dataandcrowdsec-configvolumes, which switching it off keeps. - Its input is Caddy’s access log, mounted read-only from the
caddy-logsvolume and parsed by thecrowdsecurity/caddycollection. While CrowdSec is managed the log is kept on, and in JSON, whatever Settings → Observability → Access Logging says - it is all CrowdSec has to decide on. - The bouncer key is generated by the controller, stored encrypted, never shown, and handed to Compose through the agent’s environment rather than written to a file. The container registers it on its first start.
- Its network is a
crowdsecnetwork that only Caddy shares. Containers you attach tocaddy-networkfor Caddy to proxy to cannot reach the Local API, and Caddy reaches it ascpm-crowdsec, a name unlikely to clash with one of yours.
Share signals with CrowdSec’s Central API is off by default. Off, the container never registers with CrowdSec’s online service, and nothing leaves the host but the downloads of its detection rules. On, it sends CrowdSec the addresses it decides against and receives the community blocklist in return - more addresses blocked, at the cost of telling CrowdSec who attacked you.
Inspect requests with AppSec sends every request on an HTTP host to the container’s AppSec component, loaded with CrowdSec’s virtual patching and generic rules. See AppSec.
Only hosts the bundled agent serves are checked in this mode: Caddy on another agent’s host cannot reach the container, so its hosts are served unchecked. To cover a remote agent, run CrowdSec there yourself and use External, which every agent follows.
cscli works inside the container, for example to lift a ban:
docker exec caddy-proxy-manager-crowdsec cscli decisions delete --ip <address>External
Section titled “External”CPM only makes Caddy a bouncer of an external CrowdSec. Feeding it Caddy’s log and letting Caddy reach it are yours to set up, and either archive works.
-
Turn on Settings → Observability → Access Logging, then give CrowdSec Caddy’s access log. It is
access.logon thecaddy-logsvolume, which Compose names<project>_caddy-logs- the project is the directory you unpacked into, unlessCOMPOSE_PROJECT_NAMEsays otherwise. Mount it read-only into your CrowdSec container, install thecrowdsecurity/caddycollection, and add an acquisition file such asacquis.d/cpm.yaml:filenames:- /logs/caddy/access.log# Caddy may create the log after CrowdSec starts; without this it is never read.force_inotify: truelabels:type: caddyA CrowdSec on another machine needs the log shipped there some other way - the bouncer works without it, but then decides only on what CrowdSec learns elsewhere.
-
Put Caddy and CrowdSec on a network they share. Attaching CrowdSec to
<project>_caddy-networkworks, but every upstream on it can then reach the Local API too. A network of their own avoids that, the way managed mode does - create it, attach CrowdSec, and add it to Caddy in adocker-compose.override.ymlbeside the compose file:services:caddy:networks:crowdsec-external: {}networks:crowdsec-external:external: truename: crowdsec-external # the network you createdThen
docker compose restart agent: the agent starts Caddy as it comes up, and Compose recreates it on the new network. Notdocker compose up caddyby hand, which leaves out the L4 ports and build the agent adds. A Local API on another machine needs no network, only an address Caddy can route to. -
Create a bouncer key:
Terminal window docker exec <crowdsec-container> cscli bouncers add caddy -
Open Settings → CrowdSec, switch it on, choose External, and enter the Local API URL as Caddy reaches it (for example
http://crowdsec:8080) and the bouncer key. For AppSec, also enter its URL (for examplehttp://crowdsec:7422); CrowdSec needs anappsecacquisition listening there. Test connection asks the LAPI whether it accepts the key. -
Save and apply. Every proxy host and L4 host now checks its clients.
Test connection runs from the controller, not from Caddy. A LAPI that only Caddy’s network can
reach - a crowdsec container attached to caddy-network, say - reads as unreachable there while
Caddy reaches it fine. Look for CrowdSec lines in Caddy’s log after applying instead.
What a refused client sees
Section titled “What a refused client sees”| Decision | HTTP | L4 |
|---|---|---|
| Ban | 403, plain text | Connection closed |
| Captcha | 403, like a ban - there is no captcha page | Connection closed |
| Throttle | 429 with Retry-After |
Connection closed |
The check runs first in every host’s chain, after compression and ahead of rate limiting, geo blocking and the WAF, so a banned address never costs a WAF inspection. Decisions are streamed: Caddy pulls new ones every decision refresh interval (60 seconds by default), so a fresh ban takes up to that long to apply, and a lookup never waits on the LAPI. If the LAPI is down, Caddy keeps serving with the decisions it has.
Per host
Section titled “Per host”A host follows the global setting unless it opts out. The switch sits beside the WAF card in the
proxy host editor, and in the CrowdSec section of the L4 host editor. The dashboard
host follows the global setting too: a ban on your own address locks
you out of the dashboard through its domain, until cscli decisions delete --ip <address> lifts
it.
Behind a proxy
Section titled “Behind a proxy”The bouncer checks the client address Caddy resolves through Settings → Network → Trusted Proxies. Behind a load balancer or CDN, trust its ranges there, or every request is matched on the proxy’s address - and a ban on that address refuses everyone. On an L4 host receiving PROXY protocol, the address inside the PROXY header is the one checked.
AppSec
Section titled “AppSec”CrowdSec’s AppSec component inspects requests
themselves, like a WAF. Switch it on in managed mode, or enter its URL (for example
http://crowdsec:7422) in external mode, and every request on an HTTP host is also sent there, after rate limiting and geo blocking and before the WAF. By default a
request is refused while AppSec cannot be reached or answers with an error; Let requests through
when AppSec is unavailable reverses that. AppSec does not apply to L4 hosts.
The bouncer key
Section titled “The bouncer key”In managed mode CPM makes the key and nobody types it. In external mode, the key is stored
encrypted, never shown again, and left out of REST and GraphQL reads (they report only hasApiKey)
and of the config diff in the review sheet. Leaving the field blank keeps the stored key, but only
while both the Local API and AppSec addresses stay the same - move either and the key has to be
entered again, so a stored key cannot be redirected to another server. A Caddy placeholder such as
{env.CROWDSEC_API_KEY} works too and keeps the key out of the database, though Test connection
cannot check it. The variable has to be in Caddy’s own environment, which the bundled caddy
service does not pass: add it under caddy.environment in a docker-compose.override.yml. The
agent never reads .env, so a ${CROWDSEC_API_KEY} there also needs the variable forwarded under
agent.environment.
In external mode every agent whose Caddy has the module compiled in is sent the key; in managed
mode only the bundled agent is. Plain http is accepted only for a private address; a LAPI on the
internet needs https.
Turning it off
Section titled “Turning it off”Switching CrowdSec off in Settings removes the check from every host at the next apply, and in
managed mode stops the container. Its volumes are kept; remove them with
docker volume rm <project>_crowdsec-data <project>_crowdsec-config if you are done with it. The
CrowdSec module cannot be dropped from the Caddy build while CrowdSec is switched on - the save is
refused until you turn it off first.