Skip to content

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 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.

  1. Open Settings → CrowdSec, switch it on, and choose Managed (bundled host only) as the Local API.

  2. Save and apply. The bundled agent starts the crowdsec container. 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 the crowdsec Compose profile, the way analytics runs ClickHouse. The agent passes --profile crowdsec itself; docker compose up alone never starts it. Its state lives in the crowdsec-data and crowdsec-config volumes, which switching it off keeps.
  • Its input is Caddy’s access log, mounted read-only from the caddy-logs volume and parsed by the crowdsecurity/caddy collection. 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 crowdsec network that only Caddy shares. Containers you attach to caddy-network for Caddy to proxy to cannot reach the Local API, and Caddy reaches it as cpm-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:

Terminal window
docker exec caddy-proxy-manager-crowdsec cscli decisions delete --ip <address>

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.

  1. Turn on Settings → Observability → Access Logging, then give CrowdSec Caddy’s access log. It is access.log on the caddy-logs volume, which Compose names <project>_caddy-logs - the project is the directory you unpacked into, unless COMPOSE_PROJECT_NAME says otherwise. Mount it read-only into your CrowdSec container, install the crowdsecurity/caddy collection, and add an acquisition file such as acquis.d/cpm.yaml:

    filenames:
    - /logs/caddy/access.log
    # Caddy may create the log after CrowdSec starts; without this it is never read.
    force_inotify: true
    labels:
    type: caddy

    A 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.

  2. Put Caddy and CrowdSec on a network they share. Attaching CrowdSec to <project>_caddy-network works, 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 a docker-compose.override.yml beside the compose file:

    services:
    caddy:
    networks:
    crowdsec-external: {}
    networks:
    crowdsec-external:
    external: true
    name: crowdsec-external # the network you created

    Then docker compose restart agent: the agent starts Caddy as it comes up, and Compose recreates it on the new network. Not docker compose up caddy by 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.

  3. Create a bouncer key:

    Terminal window
    docker exec <crowdsec-container> cscli bouncers add caddy
  4. 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 example http://crowdsec:7422); CrowdSec needs an appsec acquisition listening there. Test connection asks the LAPI whether it accepts the key.

  5. 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.

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.

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.

CrowdSec on a proxy hostOn by default: turning it off here opts this one host out.DemoNothing you change here is saved
CrowdSecRefuse clients your CrowdSec Local API has a decision against, before rate limiting, geo blocking and the WAF. Applies once CrowdSec is set up under Settings > CrowdSec.

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.

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.

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.

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.