Traefik docker provider skips routing for containers whose Docker HEALTHCHECK reports unhealthy. Our /healthz returns 503 if LAPI is unreachable, which left the container stuck unhealthy and the router never appeared — every request returned 404 from Traefik. /healthz still exists for manual probes; Kuma probes via Traefik also still work. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
crowdsec-admin
Tiny Flask + htmx webapp to list and delete CrowdSec decisions (bans) from a browser, gated behind Authentik.
Built so the admin can unban an IP without SSH when their own network routes change. Source of truth: this Gitea repo. Push to main → Gitea Actions runner rebuilds and redeploys via docker compose on the host.
Routes
GET /— UIGET /decisions— list active decisions (htmx fragment), optional?ip=POST /unban— delete by decisionidor byipPOST /unban-me— delete decisions for the caller's IP (usesX-Forwarded-For)GET /healthz— JSON, returns 503 if LAPI unreachable
How it talks to CrowdSec
LAPI lives on the host at 0.0.0.0:8080. Container reaches it via host.docker.internal:host-gateway.
LAPI DELETE /v1/decisions requires machine JWT (bouncers are read-only). App registers as machine via cscli machines add, logs in to /v1/watchers/login, caches the JWT ~13 minutes, and refreshes on 401.
One-time setup
-
Register the machine on the host (needs sudo, choose a strong password and save it in
secrets.yml):sudo cscli machines add crowdsec-admin --password '<PW>' -
Gitea repo secrets (Settings → Secrets and variables → Actions):
LAPI_MACHINE_ID=crowdsec-adminLAPI_MACHINE_PASSWORD=<PW from step 1>
-
DNS:
crowdsec.domverse-berlin.eu→ host IP. -
Authentik: nothing — the existing wildcard
forward_domainprovider covers*.domverse-berlin.eu, and theauthentik@dockerlabel indocker-compose.ymlgates the router. -
First deploy: push to
mainor triggerworkflow_dispatch. Runner builds the image, brings the stack up, and reports health.
Layout
crowdsec-admin/
├── .gitea/workflows/deploy.yml # CI/CD on push to main
├── docker-compose.yml # Traefik labels + Kuma + authentik@docker
├── app/
│ ├── Dockerfile # python:3.12-slim + gunicorn
│ ├── requirements.txt
│ ├── app.py # Flask, ~120 LOC
│ └── templates/
│ ├── index.html # full page
│ ├── _decisions.html # htmx fragment
│ └── _unban_me.html # htmx fragment
└── README.md
Local dev
docker compose up --build -d
# or, on the host:
LAPI_URL=http://localhost:8080 LAPI_MACHINE_ID=... LAPI_MACHINE_PASSWORD=... \
python -m flask --app app/app.py run --port 8000
Security notes
- Authentik is the only auth gate. Admin compromise = unban anything.
- App never
execs shell. Only LAPI HTTP. IP inputs validated withipaddress.ip_address. Decision-id input validatedisdigit(). TRUSTED_PROXY_HOPS=1picks the IP Traefik saw. Increase if a second proxy sits in front.
Pattern note
This stack is not Portainer-managed — it follows the same convention as the flight-radar repo: source in Gitea, runner brings the stack up on the host via docker compose. Other (non-CI) stacks are deployed via Portainer API per the infra rule.