Skip to content

CrowdSec Factories ​

Experimental

CrowdSec factories are experimental. The API may change in future releases.

CrowdSec reads your edge's access logs, detects abusive clients (scanners, brute force, known CVE exploits), and hands out decisions that a bouncer enforces. This module installs CrowdSec next to a Traefik edge and gives you the Traefik-side pieces as plain objects.

Verified against:

ComponentVersionLicense
crowdsec chart, https://crowdsecurity.github.io/helm-charts0.24.2 (CrowdSec v1.8.1)MIT (chart and CrowdSec)
Traefik bouncer plugin github.com/maxlerebourg/crowdsec-bouncer-traefik-pluginv1.7.1Apache-2.0

The chart ships no CRDs, so there is one HelmRelease and no CRD policy.

Installation ​

typescript
import * as crowdsec from 'typekro/crowdsec';

typekro/crowdsec is a subpath export only; it is not in the root typekro barrel. It does not import typekro/traefik: the Traefik helpers return plain objects you pass to the Traefik factories.

What runs ​

client ─▶ Traefik ─▶ [crowdsec bouncer Middleware] ─▶ your API
             │               │  ▲ decisions (stream, every 15s)
   access log│               │  │ AppSec check (per request, optional)
             ▼               ▼  │
   agent DaemonSet ──alerts──▶ LAPI ◀── AppSec Deployment
  • LAPI (Local API) stores alerts and decisions and serves them to bouncers. ClusterIP only; the chart's LAPI Ingress is pinned off. SQLite on a PVC (one replica) or an existing Postgres (any number of replicas). Chart 0.24 no longer ships the Metabase dashboard, so there is nothing to switch off.
  • Agents run as a DaemonSet and read the Traefik pods' container log files on each node.
  • AppSec (optional) is CrowdSec's WAF: in-band virtual patching, which blocks the request, and out-of-band OWASP CRS, which feeds a ban scenario.
  • The bouncer is a Traefik plugin running as a Middleware. It pulls decisions from LAPI in stream mode and, with AppSec, asks AppSec about each request.

Quick example ​

typescript
import * as crowdsec from 'typekro/crowdsec';

const security = crowdsec.makeCrowdsecBootstrap({
  bouncers: [{ name: 'traefik', keySecretRef: { name: 'crowdsec-bouncer', key: 'api-key' } }],
  acquisitions: [{ namespace: 'traefik', podName: 'traefik-*' }],
  simulation: { global: true },
  appsec: {},
});

const instance = await security
  .factory('direct', { namespace: 'flux-system', waitForReady: true, timeout: 900_000, kubeConfig })
  .deploy({ name: 'crowdsec', namespace: 'crowdsec' });

// instance.status.lapiHost   → 'crowdsec-service.crowdsec.svc.cluster.local:8080'
// instance.status.appsecHost → 'crowdsec-appsec-service.crowdsec.svc.cluster.local:7422'

Available factories ​

ExportWhat it is
crowdsecBootstrapThe default composition: SQLite LAPI, agents on traefik/traefik-*, CAPI off, AppSec off.
makeCrowdsecBootstrap(options)Build a composition from build-time options (below).
crowdsecHelmRepositoryBootstrapThe shared HelmRepository, owned by a singleton.
crowdsecHelmRepository, crowdsecHelmReleaseThe Flux resources, if you assemble your own graph.
mapCrowdsecConfigToHelmValuesThe values mapper the bootstrap uses.
crowdsecTraefikPlugin({ version?, hash? })One entry for the Traefik bootstrap's plugins option (Traefik's experimental.plugins).
crowdsecBouncerMiddleware(options)A Traefik Middleware.spec that runs the bouncer. Throws on what the plugin would refuse (Traefik then drops every route using it) and on unsafe trust: a blank apiKeyFile or lapiHost, a host with a scheme, updateIntervalSeconds below 1, a negative appsecBodyLimit, a pluginName that is not a flag-safe word (^[A-Za-z][A-Za-z0-9_-]*$, as Traefik's plugin declarations require), trusted IPs that are not IPs or CIDRs, any /0 range in forwardedHeadersTrustedIps (any client could spoof X-Forwarded-For) or clientTrustedIps (it would turn the bouncer off). Warns when failClosedAfter is set without failOpen: false. An empty appsecHost leaves AppSec off.
crowdsecSecretUrn(secret)urn:k8s:secret:<name>:<key>, which Traefik resolves in plugin config. Throws on a name that is not a DNS-1123 subdomain or a key outside [-._a-zA-Z0-9]+.

Runtime spec and status ​

The runtime spec only carries values that may be schema references in KRO mode: name (at most 33 characters, so the chart's longest generated name, a volume, fits in 63), namespace, chartVersion, and per component resources (requests are required, so no pod is BestEffort) plus appsec.replicas.

Everything that decides what CrowdSec runs is a build-time option, because it is rendered into CrowdSec's own config files.

Status fields are all read from the owned HelmRelease, so they hydrate the same way in direct and KRO mode: ready, failed, phase, lapiHost, appsecHost ('' when AppSec is off) and version (the chart version Flux installed).

Build-time options ​

OptionDefaultNotes
storage{ type: 'sqlite', size: '1Gi' }Or { type: 'postgres', host, database, user, passwordSecretRef, port?, sslMode? }. Use a generated alphanumeric password (-, _ and . are safe too). CrowdSec merges and re-encodes its config files, then substitutes the password and parses the result as a plain YAML scalar, then puts it unquoted into a Postgres connection string, and it has no password-file option. So the password must read back unchanged as a plain scalar: no whitespace or backslash, no leading quote or YAML indicator character (-?:,[]{}#&*!|>'"%@), no : or #, and not null or ~ (avoid values YAML reads as booleans or numbers too).
lapi.replicas1More than one needs Postgres.
lapi.env, agent.envnoneExtra env, appended after the env this factory sets. Raw values.lapi.env / values.agent.env would be replaced, so use these.
lapi.pdb, appsec.pdbtruemaxUnavailable: 1, which never blocks a drain. The chart has no PDB.
lapi.placement, agent.placement, appsec.placementsoft spread across nodesnodeSelector, tolerations, affinity, topologySpreadConstraints, priorityClassName. Give the agents the same tolerations as Traefik so they run wherever Traefik does.
acquisitions[{ namespace: 'traefik', podName: 'traefik-*' }]Namespace plus pod-name glob; program defaults to traefik.
collectionsnoneAdded to crowdsecurity/traefik, crowdsecurity/base-http-scenarios, crowdsecurity/http-cve.
centralApiabsent: offline{ communityBlocklist, enrollment? }. See Enrollment.
bouncersnone[{ name, keySecretRef }], registered as BOUNCER_KEY_<name>.
allowlistnone{ ips?, cidrs?, reason? }. See Allowlists.
simulationoff{ global: true, enforce? } or { global: false, simulate? }. See Rollout.
networkPolicyabsent: none{ traefikNamespace, metricsNamespace? }. See Network policy.
appsecabsent: offSee AppSec.
metricsmetrics on, no monitorsEvery pod serves Prometheus on :6060. serviceMonitor / podMonitor need the Prometheus Operator CRDs.
agent.containerRuntime'containerd'The container log format on the nodes.
install, upgrade, driftDetectionsee belowThe Flux lifecycle options every TypeKro HelmRelease factory takes. See Install, upgrade and CRD policy
valuesnoneRaw chart values, merged first. Everything this factory sets wins.

Default requests: LAPI 100m CPU / 256Mi, agent 100m / 192Mi, AppSec 200m / 384Mi. Memory is limited (512Mi, 384Mi, 768Mi); CPU is not, because AppSec answers on the request path and a throttled LAPI delays every decision pull. Override per component with the runtime spec's resources.

Why there is no label-selector acquisition ​

The DaemonSet agents read node-local files named /var/log/containers/<pod>_<namespace>_<container>-<id>.log, which carry the pod name but no labels. CrowdSec 1.8 does have a kubernetes datasource that selects pods by label, but it streams logs through the API: every agent in a DaemonSet would read every matching pod, counting each line once per node. It would need a single-replica agent Deployment with RBAC for pods/log, and the 0.24.2 chart's values.schema.json rejects the datasource anyway. Name the Traefik pods with a glob instead; traefikBootstrap names them <release>-<hash>. For any other source the chart accepts (syslog, Loki, CloudWatch, ...), pass it through raw values.agent.additionalAcquisition.

Wiring Traefik ​

Three things on the Traefik side:

  1. Declare the plugin with the bootstrap's plugins option. crowdsecTraefikPlugin() returns { moduleName, version: 'v1.7.1', hash }, where hash is the SHA-256 of the archive Traefik downloads. Traefik refuses an archive whose hash differs. Any other version needs its own hash.
  2. Keep the fields the CrowdSec parser reads in Traefik's JSON access log, with the crowdsec access-log preset. It keeps the User-Agent, which the chart drops by default.
  3. Create the Middleware with crowdsecBouncerMiddleware(...) and put it first in each route's middleware list.
typescript
import { crowdsecTraefikPlugin } from 'typekro/crowdsec';
import { makeTraefikBootstrap } from 'typekro/traefik';

export const edge = makeTraefikBootstrap({
  plugins: { crowdsec: crowdsecTraefikPlugin() },
  accessLog: { preset: 'crowdsec' },
});

Declaring a plugin turns on abortOnPluginFailure, so Traefik refuses to start without the bouncer instead of starting without it. To keep a plugin-registry outage from blocking a Traefik start, vendor the plugin with localPlugins instead. crowdsecBouncerMiddleware is a plain Middleware spec that names the plugin, so it works with either. The plugin name (pluginName, default crowdsec) follows the same rule as the bootstrap's plugins keys.

The route: bouncer first ​

typescript
import { crowdsecBouncerMiddleware } from 'typekro/crowdsec';
import { traefikIngressRoute, traefikMiddleware } from 'typekro/traefik';

const bouncer = traefikMiddleware({
  name: 'crowdsec',
  namespace: 'traefik',
  spec: crowdsecBouncerMiddleware({
    // From the bootstrap's status; inside a parent composition, use the
    // nested composition's status instead. `appsecHost` is '' when AppSec is
    // off, which leaves AppSec off in the bouncer too (a reference becomes the
    // CEL `appsecHost != ""`).
    lapiHost: instance.status.lapiHost,
    appsecHost: instance.status.appsecHost,
    apiKeySecret: { name: 'crowdsec-bouncer', key: 'api-key' },
  }),
  id: 'crowdsecBouncer',
});

const route = traefikIngressRoute({
  name: 'api',
  namespace: 'traefik',
  spec: {
    entryPoints: ['websecure'],
    ingressClassName: 'traefik',
    tls: { secretName: 'api-tls' },
    routes: [
      {
        match: 'Host(`api.example.com`)',
        kind: 'Rule',
        // The bouncer runs before authentication, rate limits and the backend.
        middlewares: [{ name: 'crowdsec' }, { name: 'api-authz' }, { name: 'api-rate-limit' }],
        services: [{ name: 'api', namespace: 'api', port: 8080 }],
      },
    ],
  },
  id: 'apiRoute',
});
route.dependsOn(bouncer);

The bouncer key ​

The key is one Secret value used twice: LAPI registers it from bouncers[].keySecretRef in the CrowdSec namespace, and the Middleware reads it as urn:k8s:secret:<name>:<key> from its own namespace. Create the same Secret in both namespaces, for example with External Secrets. Generate a long random value (openssl rand -hex 32).

The resolved key is part of Traefik's dynamic configuration, and Traefik's API (/api/http/middlewares) returns plugin configuration unredacted. The traefikBootstrap keeps the API and dashboard off, so nothing serves it. If your Traefik exposes the API, mount the Secret into the Traefik pods (deployment.additionalVolumes / additionalVolumeMounts) and pass apiKeyFile: '/path/to/key' instead of apiKeySecret; the plugin then reads the key from the file (crowdsecLapiKeyFile) and the API shows only the path.

LAPI only adds a bouncer whose name is not registered yet. To rotate a key, delete the bouncer (cscli bouncers delete traefik in the LAPI pod), update both Secrets, and restart LAPI and Traefik.

Client IPs ​

CrowdSec bans the client address Traefik logs. Behind a load balancer that address must be the real client: use PROXY protocol on the entrypoint, or list the load balancer's ranges in Traefik's forwardedHeaders.trustedIPs and in the bouncer's forwardedHeadersTrustedIps. Otherwise every request appears to come from the load balancer, and the base install's crowdsecurity/whitelists parser ignores private addresses, so nothing is ever banned.

Fail-open semantics ​

failOpen: true (the default) keeps the API up when CrowdSec is not:

SituationfailOpen: truefailOpen: false
LAPI unreachable during a decision pullKeep the last decisions; let new clients through (updateMaxFailure: -1).Keep serving through failClosedAfter failed pulls in a row (default 4, about a minute at the 15 s interval), then block every request until a pull succeeds (updateMaxFailure: failClosedAfter).
AppSec unreachableLet the request through (crowdsecAppsecUnreachableBlock: false).Block it.
AppSec returns 500Let the request through (crowdsecAppsecFailureBlock: false).Block it.
Body cannot be buffered for AppSec (HTTP/2 stream without length)Forward headers only (crowdsecAppsecUnreadableBodyBlock: false).Block it.
Traefik starts while LAPI is downThe first pull waits up to 10 s, then serves.The first pull fails; traffic flows until failClosedAfter pulls have failed, then everything is blocked.

failOpen, failClosedAfter and appsecHost may be schema references in KRO mode: the fail-open choice is then emitted as CEL (failOpen ? -1 : int(failClosedAfter), and !(failOpen) for the AppSec blocks), never decided at build time. Each reference is guarded with has(), so a field left unset on the instance takes the same default as in direct mode (fail open, 4 failed pulls, AppSec off). int() keeps both branches int when the schema field is a number, and a negative failClosedAfter counts as 0, which blocks at the first failure.

Known bans keep working while LAPI is down either way: they are cached in Traefik. The bouncer only bans. It configures no captcha provider, so a captcha decision is enforced as a ban.

Fail-closed needs a highly available LAPI. Run Postgres with two or more LAPI replicas. With single-replica SQLite the Deployment uses Recreate, so every LAPI restart, upgrade or node drain is a gap with no LAPI at all, and a gap longer than the tolerance blocks all traffic.

The plugin's state is per Traefik process, not per Middleware. The plugin runs one decision stream and one health flag for the whole Traefik instance, started by the first CrowdSec Middleware Traefik loads. Several CrowdSec Middlewares with different failOpen, failClosedAfter or updateIntervalSeconds do not behave independently: the first one's settings win. Use one bouncer Middleware per Traefik installation and reference it from every route.

The plugin download is a startup dependency. Traefik downloads the plugin from plugins.traefik.io when a pod starts. With abortOnPluginFailure (the default once plugins declares one), a pod that cannot reach the registry does not start. Without it, the pod starts without the plugin, and every route that references the CrowdSec Middleware fails, whatever failOpen says. Vendor the plugin with localPlugins so a pod start no longer depends on the registry.

Simulation-first rollout ​

  1. Simulate. Deploy with simulation: { global: true }. Scenarios raise alerts and simulated decisions, which bouncers ignore. With AppSec, in-band matches answer allow (the generated AppSec policy sets default_remediation: allow under global simulation) and out-of-band CRS bans are simulated too, because the bootstrap mounts simulation.yaml on the AppSec pods as well as the agents.
  2. Review. Watch cscli alerts list and the CrowdSec metrics for a week of normal traffic. Allowlist your own egress, monitors and partners, and add AppSec exclusions for false positives.
  3. Enforce the obvious. List the high-confidence scenarios in simulation: { global: true, enforce: [...] }, for example crowdsecurity/http-cve-probing. Everything else stays simulated.
  4. Enforce everything. Set simulation: { global: false }. To keep a few noisy scenarios observing, list them: simulation: { global: false, simulate: ['crowdsecurity/http-crawl-non_statics'] }. AppSec in-band bans once global simulation is off; appsec.inBandRemediation: 'allow' keeps it in observe mode longer.

Both shapes render CrowdSec's simulation.yaml, whose exclusions list inverts with the global flag: with simulation: true it names the scenarios that enforce, with simulation: false the ones that are simulated. The enforce and simulate names make that explicit.

Enrollment ​

Offline is the default: LAPI runs with DISABLE_ONLINE_API=true, shares nothing and pulls no community blocklist.

For production, turn on the Central API with centralApi: { communityBlocklist: true }. LAPI then registers with CAPI on its own, pulls the community blocklist of IPs that other CrowdSec users are seeing attack, and shares signals about the attacks it sees. This needs no console account: the registration is anonymous and automatic.

Enrolling in the CrowdSec console is a separate, optional step that adds a web UI, alert history and the ability to subscribe to extra blocklists:

  1. Create an account on the CrowdSec console and copy the enrollment key.
  2. Store it in a Secret in the CrowdSec namespace (crowdsec-enroll, key key).
  3. Deploy with:
    typescript
    centralApi: {
      communityBlocklist: true,
      enrollment: { keySecretRef: { name: 'crowdsec-enroll', key: 'key' }, tags: ['edge'] },
    }
  4. Accept the instance in the console.

With centralApi set, LAPI registers with the Central API (CAPI), shares signals about the attacks it sees, and pulls the community blocklist. Set communityBlocklist: false to register and enroll without pulling it. CAPI credentials are kept in a Secret so several LAPI replicas share one identity.

Allowlists ​

allowlist.ips and allowlist.cidrs are rendered as a parser whitelist in stage s02-enrich on the agents, next to the hub's own crowdsecurity/whitelists. CrowdSec recommends parser whitelists for plain IP and range lists: the event is dropped before any scenario sees it, which is the cheapest place. The chart mounts parsers only on agents, so for AppSec the same list is also rendered as a postoverflow whitelist (s01-whitelist).

Two limits:

  • A whitelist stops new decisions. It does not lift decisions that already exist, and it does not stop AppSec in-band blocking. Use the bouncer's clientTrustedIps for clients that must never be checked at all.
  • CrowdSec's newer centralized allowlists (cscli allowlists) live in LAPI and also filter blocklists, but they are managed imperatively, so this factory does not use them.

AppSec ​

typescript
appsec: {
  virtualPatching: true,          // in-band: crowdsecurity/appsec-default
  crs: true,                      // out-of-band: crowdsecurity/crs
  maxBodySize: 1_048_576,         // bytes inspected
  bodySizeExceededAction: 'partial',
  exclusions: [
    { ruleId: 942100, pathPrefix: '/v1/uploads', phase: 'outofband' },
    { ruleName: 'crowdsecurity/vpatch-env-access', pathPrefix: '/health' },
  ],
}

The AppSec pods load crowdsecurity/appsec-default, crowdsecurity/crs and a generated typekro/appsec-policy config, in that order. The policy config carries the body limit (SetMaxBodySize, SetBodySizeExceededAction) and the exclusions: an exclusion without pathPrefix removes the rule at load time, one with a prefix removes it per request. Set the bouncer's appsecBodyLimit to the same size, so the plugin does not send more than AppSec reads.

Network policy ​

networkPolicy: { traefikNamespace: 'traefik' } adds two NetworkPolicies:

  • LAPI accepts :8080 only from the release's agent and AppSec pods and from pods in traefikNamespace (the bouncer).
  • AppSec accepts :7422 only from pods in traefikNamespace.

Both accept :6060 (Prometheus) from metricsNamespace, or from any namespace when it is unset. The agents need no ingress besides metrics and get no policy. Any other bouncer outside traefikNamespace, and a Traefik running with hostNetwork (its traffic comes from the node, not a pod), is blocked unless you extend the policies. It needs a CNI that enforces NetworkPolicy; most such CNIs let kubelet probes through, which come from the node.

In direct mode, the policies need the NetworkPolicy fix in #285: before it, direct-mode deploys dropped every ingress[].from peer, so the rules admitted any source on their ports. KRO mode is not affected.

Limits ​

  • The bouncer plugin (v1.7.1) in stream mode matches decisions by exact IP: its cache is keyed by the decision's value. A range decision (cscli decisions add --range ...) is pulled but never matches a client. Ban addresses, or run the plugin in live mode (not modelled here), where LAPI does the matching. The same applies to range entries in blocklists.
  • One CrowdSec release per namespace: the chart uses fixed ConfigMap names.
  • Traffic between the bouncer and LAPI is plain HTTP inside the cluster. The chart's TLS mode needs cert-manager and is not modelled yet; restrict it with networkPolicy.
  • Collections are installed from the CrowdSec hub when pods start, so the pods need outbound access to the hub.

Released under the Apache 2.0 License.