Protect the dashboard with CrowdSec AppSec

Updated

This guide shows how to protect a self-hosted NetBird dashboard with CrowdSec AppSec when NetBird is deployed with the internal Traefik reverse proxy.

The configuration reuses the CrowdSec LAPI container created by the NetBird quickstart when you enable the NetBird Proxy CrowdSec integration. NetBird Proxy and Traefik use separate bouncer keys against the same CrowdSec service.

For the NetBird Proxy IP reputation integration, see CrowdSec IP Reputation.

For updates to Traefik, CrowdSec, or the bouncer plugin, see Upgrading Traefik and CrowdSec.

Prerequisites

  • A self-hosted NetBird deployment using the internal Traefik reverse proxy.
  • NetBird Proxy enabled with CrowdSec IP reputation blocking.
  • A Linux host reachable on TCP 80 and 443, and UDP 3478.
  • A domain such as netbird.example.com pointing to the host.
  • A wildcard record such as *.netbird.example.com pointing to the same host if you use NetBird Proxy.
  • Docker with Compose v2.
  • curl, jq, and openssl.

Set these shell variables before running the commands in this guide:

export NETBIRD_INSTALL_DIR="/path/to/netbird/install"
export NETBIRD_DOMAIN="netbird.example.com"

NETBIRD_INSTALL_DIR is the directory that contains the generated docker-compose.yml, proxy.env, dashboard.env, and crowdsec/ files.

Deploy NetBird with Traefik and CrowdSec

For a new deployment, follow the Self-hosting Quickstart Guide. When the quickstart asks for deployment options, choose:

Reverse proxy: 0, Traefik
Enable proxy: y
Enable CrowdSec IP reputation blocking: y

Enabling CrowdSec creates a local crowdsec service, registers a netbird-proxy bouncer, and writes NB_PROXY_CROWDSEC_API_URL and NB_PROXY_CROWDSEC_API_KEY to proxy.env.

If you already have a self-hosted NetBird deployment without NetBird Proxy, follow Enable Reverse Proxy Feature first.

Add a dashboard bouncer key

Create a dedicated bouncer key for the Traefik plugin and store it in the Compose project .env file:

cd "$NETBIRD_INSTALL_DIR"
umask 077
if ! grep -q '^CROWDSEC_DASHBOARD_BOUNCER_KEY=.' .env 2>/dev/null; then
  CROWDSEC_DASHBOARD_BOUNCER_KEY=$(openssl rand -hex 32) || exit 1
  if [ -z "$CROWDSEC_DASHBOARD_BOUNCER_KEY" ]; then
    echo "Failed to generate a dashboard bouncer key." >&2
    exit 1
  fi
  printf 'CROWDSEC_DASHBOARD_BOUNCER_KEY=%s\n' "$CROWDSEC_DASHBOARD_BOUNCER_KEY" >> .env || exit 1
fi
chmod 600 .env

Rerunning this command preserves an existing non-empty key. chmod 600 restricts .env to its owner, since the file now holds the bouncer key.

Add CrowdSec AppSec acquisition

Create the AppSec acquisition file:

mkdir -p "$NETBIRD_INSTALL_DIR/crowdsec/acquis.d"
cat >"$NETBIRD_INSTALL_DIR/crowdsec/acquis.d/appsec.yaml" <<'EOF'
appsec_configs:
  - crowdsecurity/appsec-default
labels:
  type: appsec
listen_addr: 0.0.0.0:7422
source: appsec
EOF

Update docker-compose.yml

Start from the docker-compose.yml generated by the quickstart with proxy CrowdSec enabled. The file already includes a crowdsec service, and proxy.env already points NetBird Proxy to http://crowdsec:8080.

Set the Traefik image to the validated version below and add the header handling and bouncer plugin arguments to the existing services.traefik.command list:

services:
  traefik:
    image: traefik:v3.7.14
    command:
      - '--entrypoints.web.http.aliasHeadersStrategy=delete'
      - '--entrypoints.websecure.http.aliasHeadersStrategy=delete'
      - '--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin'
      - '--experimental.plugins.bouncer.version=v1.7.1'

The header settings remove request headers with names containing characters other than letters, digits, and hyphens. If you use custom headers or already set underscoreHeadersStrategy, review Traefik header handling before applying them.

Verify that the existing --providers.docker.network value matches the actual Docker network name, which can include a Compose project prefix. Follow Traefik Docker network to check it and replace a mismatched value. Update the existing flag rather than adding a second copy.

Under services.traefik.depends_on, make Traefik wait until CrowdSec is healthy:

services:
  traefik:
    depends_on:
      crowdsec:
        condition: service_healthy

Under services.dashboard.labels, add the CrowdSec middleware to the existing dashboard router:

services:
  dashboard:
    labels:
      - traefik.http.routers.netbird-dashboard.middlewares=netbird-dashboard-crowdsec@docker

In the same services.dashboard.labels list, define the middleware:

services:
  dashboard:
    labels:
      - traefik.http.middlewares.netbird-dashboard-crowdsec.plugin.bouncer.enabled=true
      - traefik.http.middlewares.netbird-dashboard-crowdsec.plugin.bouncer.crowdsecMode=stream
      - traefik.http.middlewares.netbird-dashboard-crowdsec.plugin.bouncer.crowdsecLapiScheme=http
      - traefik.http.middlewares.netbird-dashboard-crowdsec.plugin.bouncer.crowdsecLapiHost=crowdsec:8080
      - traefik.http.middlewares.netbird-dashboard-crowdsec.plugin.bouncer.crowdsecLapiKey=${CROWDSEC_DASHBOARD_BOUNCER_KEY}
      - traefik.http.middlewares.netbird-dashboard-crowdsec.plugin.bouncer.crowdsecAppsecEnabled=true
      - traefik.http.middlewares.netbird-dashboard-crowdsec.plugin.bouncer.crowdsecAppsecHost=crowdsec:7422
      - traefik.http.middlewares.netbird-dashboard-crowdsec.plugin.bouncer.crowdsecAppsecFailureBlock=true
      - traefik.http.middlewares.netbird-dashboard-crowdsec.plugin.bouncer.crowdsecAppsecUnreachableBlock=true

Under services.netbird-server.labels, add a higher-priority router for dashboard API and embedded IdP paths:

services:
  netbird-server:
    labels:
      - traefik.http.routers.netbird-dashboard-api.rule=Host(`netbird.example.com`) && (PathPrefix(`/api`) || PathPrefix(`/oauth2`))
      - traefik.http.routers.netbird-dashboard-api.entrypoints=websecure
      - traefik.http.routers.netbird-dashboard-api.tls=true
      - traefik.http.routers.netbird-dashboard-api.tls.certresolver=letsencrypt
      - traefik.http.routers.netbird-dashboard-api.service=netbird-server
      - traefik.http.routers.netbird-dashboard-api.middlewares=netbird-dashboard-crowdsec@docker
      - traefik.http.routers.netbird-dashboard-api.priority=110

Replace netbird.example.com with your NetBird domain. Traefik labels do not read the NETBIRD_DOMAIN shell variable used in the command examples.

Merge these fields into services.crowdsec. This sets the validated image version, adds the AppSec collections, registers the dashboard bouncer key, and updates the health check so CrowdSec reports healthy only after both LAPI and AppSec are available. Keep the other generated fields, including container_name, restart, networks, labels, logging, and volumes.

The installer-created health check only verifies the CrowdSec Local API:

healthcheck:
  test: ['CMD', 'cscli', 'lapi', 'status']
  interval: 10s
  timeout: 5s
  retries: 15

Dashboard protection also needs the AppSec listener on port 7422, so replace the installer-created health check with the one below.

FieldPurpose
crowdsecurity/linuxKeeps the base Linux collection created by the installer.
crowdsecurity/appsec-virtual-patchingAdds virtual patching rules for common web application attack patterns.
crowdsecurity/appsec-generic-rulesAdds generic AppSec detection rules used by the AppSec engine.
BOUNCER_KEY_netbird_dashboardRegisters a separate Traefik dashboard bouncer key without reusing the NetBird Proxy bouncer key.
healthcheck.testReplaces the installer LAPI-only check with a check for both LAPI and the AppSec listener on 7422.
services:
  crowdsec:
    image: crowdsecurity/crowdsec:v1.8.1
    environment:
      COLLECTIONS: "crowdsecurity/linux crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules"
      BOUNCER_KEY_netbird_dashboard: "${CROWDSEC_DASHBOARD_BOUNCER_KEY}"
    healthcheck:
      test: ["CMD-SHELL", "cscli lapi status >/dev/null 2>&1 && bash -c '</dev/tcp/127.0.0.1/7422'"]
      interval: 10s
      timeout: 5s
      retries: 15
      start_period: 30s

Keep the installer-created CrowdSec volume in the top-level volumes section:

volumes:
  crowdsec_db:

Apply the change

Validate the Compose file without printing the resolved configuration, which contains the dashboard bouncer key:

cd "$NETBIRD_INSTALL_DIR"
docker compose config --quiet

Start the updated stack:

docker compose up -d
docker compose ps

Verify protection

Open https://$NETBIRD_DOMAIN in a browser and confirm the dashboard loads. Then verify the status codes from the NetBird host or another machine that can reach the dashboard.

Normal dashboard and OIDC requests should return 200:

curl -ks -A 'Mozilla/5.0 NetBirdDashboardCheck' -o /dev/null -w '%{http_code}\n' "https://$NETBIRD_DOMAIN"
curl -ks -A 'Mozilla/5.0 NetBirdDashboardCheck' -o /dev/null -w '%{http_code}\n' "https://$NETBIRD_DOMAIN/oauth2/.well-known/openid-configuration"

A known malicious probe should return 403:

curl -ks -A 'Mozilla/5.0 NetBirdDashboardCheck' -o /dev/null -w '%{http_code}\n' "https://$NETBIRD_DOMAIN/.env"

Check AppSec metrics and bouncer registration:

docker compose exec -T crowdsec cscli metrics show appsec
docker compose exec -T crowdsec cscli bouncers list

A healthy deployment shows AppSec requests being processed, the netbird-proxy bouncer from the quickstart, and the netbird_dashboard bouncer used by Traefik.

Test manual IP blocks

You can test that the Traefik bouncer blocks dashboard access by adding a temporary CrowdSec decision for your own public IP.

First, get your public IPv4 address from the same client you will use to test dashboard access:

curl -4s ifconfig.me

Run this command through the same network path as the dashboard test. NAT or proxies can change the source IP that Traefik sees.

On the NetBird host, add a short test ban:

cd "$NETBIRD_INSTALL_DIR"
TEST_IP="<your-public-ipv4>"
docker compose exec -T crowdsec cscli decisions add --ip "$TEST_IP" --duration 3m --reason "dashboard bouncer test"

If your IP is on a CrowdSec allowlist, cscli decisions add refuses the decision. Add --bypass-allowlist to the command to test anyway.

Wait up to 60 seconds because the Traefik bouncer runs in stream mode and refreshes decisions periodically. Then open your NetBird dashboard in a browser from the blocked IP. The dashboard request should be blocked.

Alternatively, verify the block with curl:

curl -ks -A 'Mozilla/5.0 NetBirdDashboardCheck' -o /dev/null -w '%{http_code}\n' "https://$NETBIRD_DOMAIN"

The response should be 403.

Confirm the decision exists:

docker compose exec -T crowdsec cscli decisions list

Remove the decision when done:

docker compose exec -T crowdsec cscli decisions delete --ip "$TEST_IP"

Wait up to 60 seconds for the bouncer to refresh, then verify access returns:

curl -ks -A 'Mozilla/5.0 NetBirdDashboardCheck' -o /dev/null -w '%{http_code}\n' "https://$NETBIRD_DOMAIN"

The response should be 200.

Verifying the middleware stays attached

This protection fails open. If the middleware is detached for any reason, most commonly a label edit applied with docker compose restart instead of docker compose up -d, which does not recreate the container, requests stop being inspected and nothing reports an error. The dashboard loads, authentication works, and all containers report healthy. The only symptom is that a known malicious probe returns 404 instead of 403.

After any change to the labels, recreate the affected containers and re-run both probes:

docker compose up -d dashboard netbird-server
curl -ks -A 'Mozilla/5.0 NetBirdDashboardCheck' -o /dev/null -w '%{http_code}\n' "https://$NETBIRD_DOMAIN/.env"
curl -ks -A 'Mozilla/5.0 NetBirdDashboardCheck' -o /dev/null -w '%{http_code}\n' "https://$NETBIRD_DOMAIN/api/.env"

A 403 from both confirms the middleware is in the request path on both routers. A 404 from either means it is not attached to that router.

To surface this automatically, monitor the same probe from outside the deployment and treat 403 as the healthy response. The middleware is attached to the two routers separately, so probe both. A check on /.env alone exercises only the dashboard router and would still return 403 if the middleware were detached from the API router. Point an uptime monitor or a cron job at each of:

https://<your-domain>/.env
https://<your-domain>/api/.env

Alert when either response is anything other than 403. This is an inverted check, alerting on the absence of a block rather than on an outage, but it is the only signal that confirms the protection layer is still in the request path.

Recovering from a dashboard lockout

If AppSec or an IP decision blocks you from the dashboard, the block also covers /api and /oauth2, so it cannot be lifted from the UI. All recovery is performed over SSH on the host.

In your SSH session, change to the NetBird install directory. Replace the path below with the directory containing your docker-compose.yml:

cd "/path/to/netbird/install"

First identify which of the two mechanisms is blocking you, because they are handled differently:

docker compose exec -T crowdsec cscli decisions list

If your address is listed, an IP decision is blocking you. This includes bans created by the crowdsecurity/appsec-vpatch scenario after repeated AppSec matches, which appear with that reason. If it is not, the block is an AppSec match on the request itself: an individual AppSec match does not create a decision, so cscli decisions delete has no effect in that case. Confirm with cscli metrics show appsec and cscli alerts list, where AppSec matches appear with kind set to waf.

Allowlisting the address resolves both cases. cscli allowlists add fails with allowlist '<name>' not found if the list does not already exist, so create it first if you have not set one up:

docker compose exec -T crowdsec cscli allowlists create netbird-admins -d "Addresses that should never be blocked"
docker compose exec -T crowdsec cscli allowlists add netbird-admins <your-ip> -d "admin"

Adding an address that currently carries a decision expires that decision immediately. Allowlisted addresses are also exempted from AppSec blocking. AppSec refreshes its allowlists every minute, so allow up to a minute for that exemption to take effect. See Allowlisting addresses for details.

If an IP decision is present and you want it gone without allowlisting the address permanently, delete it directly:

docker compose exec -T crowdsec cscli decisions delete --ip <your-ip>

For example, removing one matching decision logs:

1 decision(s) deleted

The Traefik bouncer runs in stream mode and refreshes periodically, so allow up to a minute for access to return.

If the dashboard is still unreachable and you need immediate access, detach the middleware. It is attached to two routers and both must be commented out:

services:
  dashboard:
    labels:
      # - traefik.http.routers.netbird-dashboard.middlewares=netbird-dashboard-crowdsec@docker

  netbird-server:
    labels:
      # - traefik.http.routers.netbird-dashboard-api.middlewares=netbird-dashboard-crowdsec@docker
docker compose up -d dashboard netbird-server

Restore both labels once the underlying issue is resolved, recreate the containers, and confirm with the /.env and /api/.env probes in Verifying the middleware stays attached. Run these probes from an IP outside netbird-admins and any other CrowdSec allowlist, because allowlisted IPs bypass AppSec checks.

If you use the IP you allowlisted during recovery, first remove it from netbird-admins on the NetBird host:

docker compose exec -T crowdsec cscli allowlists remove netbird-admins <your-ip>

Wait up to one minute for AppSec to refresh its allowlists, then run both probes from that IP and confirm that each returns 403.

Troubleshooting

If all dashboard requests return 403 immediately after startup, Traefik may have started before CrowdSec LAPI and AppSec were ready. Confirm that the CrowdSec health check is present and that Traefik uses depends_on.condition: service_healthy.

If CrowdSec is stopped or unreachable while crowdsecAppsecUnreachableBlock=true is set, all dashboard requests are denied by design.

Separately, a reverse proxy service in enforce mode denies all connections while its bouncer has not completed its initial sync, for example when the proxy starts with the LAPI unavailable. See Enforcement modes.

If Traefik logs warnings about a missing Docker network, follow Traefik Docker network to correct the configured name.

If AppSec metrics do not increment, confirm that the middleware is attached to both the netbird-dashboard and netbird-dashboard-api routers and that Traefik can reach crowdsec:8080 and crowdsec:7422.