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.
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
80and443, and UDP3478. - A domain such as
netbird.example.compointing to the host. - A wildcard record such as
*.netbird.example.compointing to the same host if you use NetBird Proxy. - Docker with Compose v2.
curl,jq, andopenssl.
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.
This CrowdSec dashboard protection setup requires the combined container
setup. If your deployment uses the
separate-container architecture (management, signal, relay, and
coturn), migrate to the combined container setup before enabling NetBird
Proxy.
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
printf 'CROWDSEC_DASHBOARD_BOUNCER_KEY=%s\n' "$(openssl rand -hex 32)" >> .env
fi
Rerunning this command preserves the existing 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.
Under services.traefik.command, add the Traefik CrowdSec bouncer plugin static arguments:
services:
traefik:
command:
- '--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin'
- '--experimental.plugins.bouncer.version=v1.6.0'
The generated Compose file already sets --providers.docker.network in the Traefik command block, normally to netbird, matching the network key in the Compose file. Leave it as generated and do not add a second copy of the flag.
Only change it if Traefik logs warnings about a missing Docker network. Compose prefixes the network name with the project name, which defaults to the install directory name, so the actual name varies by deployment. Check it before editing:
docker network ls | grep netbird
An install in /root produces root_netbird; one in /opt/netbird produces netbird_netbird.
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.
Leave the NetBird Proxy TCP passthrough labels unchanged. Do not attach this
middleware to the proxy-passthrough TCP router.
Merge these fields into services.crowdsec. This 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 generated fields that already exist in your Compose file, such as image, 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.
| Field | Purpose |
|---|---|
crowdsecurity/linux | Keeps the base Linux collection created by the installer. |
crowdsecurity/appsec-virtual-patching | Adds virtual patching rules for common web application attack patterns. |
crowdsecurity/appsec-generic-rules | Adds generic AppSec detection rules used by the AppSec engine. |
BOUNCER_KEY_netbird_dashboard | Registers a separate Traefik dashboard bouncer key without reusing the NetBird Proxy bouncer key. |
healthcheck.test | Replaces the installer LAPI-only check with a check for both LAPI and the AppSec listener on 7422. |
services:
crowdsec:
...
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"
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"
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 the probe:
docker compose up -d dashboard netbird-server
curl -ks -o /dev/null -w '%{http_code}\n' "https://$NETBIRD_DOMAIN/.env"
A 403 confirms the middleware is in the request path. A 404 means it is not.
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.
Do not enable the Traefik API (--api.insecure=true) for this purpose. The
API serves the full dynamic configuration without authentication, including
middleware plugin settings such as crowdsecLapiKey. Even with port 8080
unpublished, every container on the Docker network could then read the
CrowdSec bouncer key.
This is detection only. Nothing here restores enforcement automatically: the probe tells you protection has stopped, and reattaching the middleware remains a manual step.
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.
First identify which of the two mechanisms is blocking you, because they are handled differently:
docker compose exec crowdsec cscli decisions list
If your address is listed, an IP decision is blocking you. If it is not, the block is an AppSec match on the request itself: AppSec inspects requests and does not create decisions, 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 crowdsec cscli allowlists create netbird-admins -d "Addresses that should never be blocked"
docker compose exec 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, though that takes effect on the Traefik bouncer's next stream refresh rather than instantly, so allow up to a minute. 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 crowdsec cscli decisions delete --ip <your-ip>
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
Commenting out only the dashboard label leaves the dashboard reachable while
/api and /oauth2 continue to return 403. The interface loads but cannot
authenticate or fetch data, which is easily mistaken for a different fault.
Detach both labels, or neither.
Restore both labels once the underlying issue is resolved, recreate the containers, and confirm with the /.env probe above.
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, check the actual network name:
docker network ls | grep netbird
Then set --providers.docker.network to the actual network name, commonly netbird_netbird.
If AppSec metrics do not increment, confirm that the middleware is attached to the dashboard router and that Traefik can reach crowdsec:8080 and crowdsec:7422.

