Sidecar management UI for caddy-waf.
Per-site WAF mode, CRS exclusions, IP rules, and rollback - without touching your base Caddyfile.
- Overview
- Prerequisites
- Quick Start
- Architecture
- Docker deployment
- Configuration
- Tests
- Features
- Security Model
- Ansible Integration
- Integration Contract
- Rollback
- Roadmap
- Security
- Changelog
- Contributing
- License
- Contact & support
caddy-waf-ui is a self-hosted sidecar that runs alongside your caddy-waf container and exposes a minimal web UI to manage WAF behavior at runtime - without requiring access to your Ansible inventory, vault, or base Caddyfile.
It does NOT replace Ansible. Your base Caddyfile remains Ansible-owned and immutable from the UI's perspective. The UI writes exclusively to a dedicated ui-managed/ volume that Caddy imports as an overlay.
Internet β Caddy (base Caddyfile, Ansible-owned, :ro)
βββ per-slug overlay imports from ui-managed/ (INTEGRATION.md Β§5) β UI writes here only
β
caddy-waf-ui (sidecar, :rw on ui-managed only)
- Toggle
SecRuleEngineper site (DetectionOnly / On / Off) - Add/remove CRS exclusions per site (by rule ID, tag, or URI+param)
- Manage IP allowlist and denylist per site
- View and search recent Coraza detection logs
- Exclude rules from the per-domain exclusions page
- Rollback any site config to a previous snapshot
- Docker 24.x+ and Docker Compose v2.x+
- the
caddy-wafstack v3.6.1+ (imageghcr.io/developmi/caddy-waf) running withcaddy_admin_enabled: true- note: the WAF stack and this UI have independent version lines - Caddy Admin API reachable at
caddy:2019(Docker internal network only) - Tailscale or equivalent for UI access (UI binds to
0.0.0.0:8080by default)
UI bind note: the default
0.0.0.0:8080listener is reachable by any client on the LAN β over plain HTTP, because the UI does not terminate TLS itself. The session cookie'sSecureflag only applies in a secure context (HTTPS; loopback is exempt, LAN addresses are not): over a LAN IP, browsers do not honor the cookie and the bearer token used by/api/*crosses the wire in cleartext. If you do not need LAN-wide access, narrow the listener βCADDY_UI_BIND=127.0.0.1:8080for loopback-only access (recommended;Securecookies keep working over HTTP loopback), or bind to your Tailscale interface IP (tailscale ip -4) so remote access travels only over the encrypted Tailnet. The Tailscale flow above remains valid.
Three-repo deployment contract (mounts, admin API, per-slug imports, emergency/drift procedures): see INTEGRATION.md.
In your Caddyfile.j2 (Ansible template), add at the end of each UI-managed site block, in this order - ip-rules first, then waf:
{{ svc.domain }} {
import /etc/caddy/ui-managed/ip-rules-{{ svc.domain | replace('.', '_') }}.conf
import /etc/caddy/ui-managed/waf-{{ svc.domain | replace('.', '_') }}.conf
...
}Do not import waf in managed blocks (the overlay embeds its own coraza_waf - a second engine breaks the reload), and do not use a glob (*.conf): a specific import whose file is missing hard-fails Caddy startup, so Ansible must pre-create empty placeholder files before first boot. Exclusions are never imported from the Caddyfile - they enter via the Coraza internal Include inside the overlay. See INTEGRATION.md Β§5 for the full contract.
# Option A (local convenience):
cp .env.example .env
chmod 600 .env
# Edit .env - set CADDY_UI_TOKEN and CADDY_ADMIN_URL
# Option B (recommended for remote/prod):
export CADDY_UI_TOKEN="<secret>"
export CADDY_ADMIN_URL="http://caddy-waf:2019"# In your existing docker-compose.yml, add:
caddy-waf-ui:
# Tag v1.4.0 exists, and the build-scan-sign pipeline signs (keyless
# cosign) and publishes to ghcr.io/developmi/caddy-waf-ui whenever a v*
# tag is pushed - publication is at the maintainer's discretion. Pin an
# immutable digest in production (SECURITY.md: never `:latest`). Building
# from source stays the dev path (same as this repo's docker-compose.yml).
build: .
container_name: caddy-waf-ui
restart: unless-stopped
ports:
- "127.0.0.1:8080:8080"
networks:
- public_net
volumes:
- caddy-ui-config:/ui-managed:rw
- caddy-ui-backups:/backups:rw
environment:
- CADDY_UI_TOKEN=${CADDY_UI_TOKEN:-}
- CADDY_ADMIN_URL=${CADDY_ADMIN_URL:-http://caddy-waf:2019}
- CADDY_UI_CADDYFILE=${CADDY_UI_CADDYFILE:-/etc/caddy/Caddyfile}
- CADDY_UI_MANAGED_DIR=${CADDY_UI_MANAGED_DIR:-/ui-managed}
- CADDY_UI_INCLUDE_DIR=${CADDY_UI_INCLUDE_DIR:-/etc/caddy/ui-managed}
- CADDY_UI_BACKUP_DIR=${CADDY_UI_BACKUP_DIR:-/backups}
- CADDY_UI_BACKUP_KEEP=${CADDY_UI_BACKUP_KEEP:-10}
- CADDY_UI_BIND=${CADDY_UI_BIND:-0.0.0.0:8080}
- CADDY_UI_LOG_LEVEL=${CADDY_UI_LOG_LEVEL:-info}
- CADDY_UI_AUDIT_LOG=${CADDY_UI_AUDIT_LOG:-/data/logs/coraza-audit.log}
depends_on:
- caddy
volumes:
caddy-ui-config:
caddy-ui-backups:Then mount the shared volume on Caddy (:ro):
caddy:
volumes:
# ... existing volumes ...
- caddy-ui-config:/etc/caddy/ui-managed:ro # β add this linedocker compose up -d caddy-waf-uiOn a fresh environment (first start, or after docker compose down -v), the one-shot ui-config-init service creates the overlay files that the Caddyfile imports before Caddy starts - idempotent, it never overwrites existing configs (existing domains/apps keep their runtime overlays). It runs as uiuser (same uid as the UI), so the UI can always overwrite them later.
The stack boots from a clean volume with no manual steps:
docker compose up -d. If you add new UI-managed domains to the Caddyfile later,docker compose run --rm ui-config-initrecreates their missing overlays.
Access the UI at http://127.0.0.1:8080 (or via your Tailscale IP).
Single static Go binary (standard library only, no runtime dependencies). The UI runs as a sidecar next to caddy-waf, owns a dedicated ui-managed/ overlay volume, and talks to Caddy exclusively through the Admin API (POST /load + read-back verification). The full design, data flow, and the three-repo deployment contract live in ARCHITECTURE.md and INTEGRATION.md.
caddy-waf-ui/
βββ cmd/server/ # entrypoint: env config, wiring, HTTP server
βββ internal/
β βββ auth/ # bearer token + cookie-session auth, HMAC-CSRF
β βββ caddy/ # Caddy Admin API client, reload, read-back verify
β βββ config/ # centralized environment configuration
β βββ domain/ # site config model (slug, overlay generation)
β βββ files/ # atomic writes, snapshots, retention
β βββ iprules/ # IP allow/deny rule rendering
β βββ logs/ # structured JSON logging + Coraza audit reader
β βββ ratelimit/ # native sliding token-bucket rate limiter
β βββ service/ # shared chain: validate β backup β generate β write β reload β audit
β βββ ui/ # SSR pages (html/template + go:embed), PRG forms
β βββ waf/ # WAF mode / CRS exclusion rendering
βββ tests/integration/ # end-to-end API + SSR tests against a live stack
Request flow: Browser β UI (rate limit + auth + CSRF) β internal/service chain β ui-managed/ overlay β POST /load β read-back verify β audit log.
Multi-stage build (builder β runtime), non-root uiuser (UID 1000:1000), pinned base images (golang:1.27.2-alpine, alpine:3.24.2) - never :latest. Tag v1.4.0 exists, and the docker-build-scan-sign workflow builds, scans, signs (keyless cosign) and publishes to ghcr.io/developmi/caddy-waf-ui only when a v* tag is pushed - the published image appears at the maintainer's discretion, not automatically. Until then, build from source:
docker build -t caddy-waf-ui:local .The repo's docker-compose.yml wires the UI into the caddy-waf stack with shared volumes:
docker compose up -d caddy-waf-uiSecurity callout: the UI runs as a non-root user (
uiuser, UID/GID 1000) with write access only to theui-managed/and/backupsvolumes; Caddy mountsui-managed/read-only; the UI port is published on127.0.0.1only; the Caddy Admin API is never exposed to the host.Token handling:
CADDY_UI_TOKENin.envis dev-only. In production, inject it from the orchestrator's secret manager (e.g.environment: CADDY_UI_TOKEN=${CADDY_UI_TOKEN}with the value sourced outside the repo) or Docker secrets - never commit it in.envfiles.Hardened numeric
USER 1000:1000: the runtime user and group are deterministically declared as1000:1000(addgroup -g 1000 uiuser && adduser -u 1000 -G uiuser -D uiuser), strictly complying with Hadolint DL3066 while matching the exact ownership required for named volumes (ui-managed,backups).
All configuration is via environment variables. No config file, no database.
| Variable | Required | Default | Description |
|---|---|---|---|
CADDY_UI_TOKEN |
β | - | Bearer token for UI authentication |
CADDY_ADMIN_URL |
β | http://caddy-waf:2019 |
Caddy Admin API endpoint |
CADDY_UI_CADDYFILE |
/etc/caddy/Caddyfile |
Caddyfile read by reload and sent to POST /load as text/caddyfile |
|
CADDY_UI_MANAGED_DIR |
/ui-managed |
Path inside container where UI writes configs | |
CADDY_UI_INCLUDE_DIR |
/etc/caddy/ui-managed |
Caddy-side view of the overlay dir, referenced by the generated Include directive (must match the mount point inside the Caddy container) |
|
CADDY_UI_BACKUP_DIR |
/backups |
Path inside container for snapshots | |
CADDY_UI_BACKUP_KEEP |
10 |
Number of snapshots to retain per domain | |
CADDY_UI_BIND |
0.0.0.0:8080 |
UI listen address | |
CADDY_UI_LOG_LEVEL |
info |
Log verbosity: debug, info, warn, error |
|
CADDY_UI_AUDIT_LOG |
/data/logs/coraza-audit.log |
Coraza audit log path read by the log viewer |
# --- UI Authentication ---
# Bearer token for UI authentication (generate with: openssl rand -hex 32)
#
# DEV-ONLY: this file is optional convenience for local runs and stores the
# token in plaintext. For production, do NOT put the token in .env - inject it
# from the orchestrator's secret manager via env interpolation instead, e.g.:
# environment:
# - CADDY_UI_TOKEN=${CADDY_UI_TOKEN} # sourced from the secret manager,
# # never committed, never in .env
# or use Docker secrets mounted to a file (the UI reads CADDY_UI_TOKEN as env).
CADDY_UI_TOKEN=change-me-generate-with-openssl-rand-hex-32
# --- Caddy Admin API (internal Docker network only) ---
CADDY_ADMIN_URL=http://caddy-waf:2019
# --- Caddyfile reload ---
# Caddyfile read by reload and sent to POST /load as text/caddyfile (10s timeout).
# If unset, defaults to /etc/caddy/Caddyfile.
CADDY_UI_CADDYFILE=/etc/caddy/Caddyfile
# --- Paths (match your volume mounts) ---
CADDY_UI_MANAGED_DIR=/ui-managed
# Caddy's view of the overlay volume: the Include directive of the generated
# overlays references it. Must match the mount point inside the Caddy
# container (see INTEGRATION.md Β§3).
CADDY_UI_INCLUDE_DIR=/etc/caddy/ui-managed
CADDY_UI_BACKUP_DIR=/backups
CADDY_UI_BACKUP_KEEP=10
# --- Runtime ---
# Listen address (restrict to loopback or Tailscale interface)
CADDY_UI_BIND=0.0.0.0:8080
# Structured JSON log level (debug, info, warn, error) - fail-safe: info
CADDY_UI_LOG_LEVEL=info
# Coraza audit log path read by the log viewer
CADDY_UI_AUDIT_LOG=/data/logs/coraza-audit.log
# --- Compose stack (optional - consumed by docker-compose.yml for caddy-waf) ---
ACME_EMAIL=admin@example.com
SITE_ADDRESS=localhost
BACKEND_UPSTREAM=example-app:80
CADDY_WAF_IMAGE=ghcr.io/developmi/caddy-waf:v3.6.1
EXAMPLE_APP_IMAGE=containous/whoami:v1.5.0Unit tests cover the service chain, overlay generation, auth (constant-time compare, sessions, CSRF), the Caddy Admin client (including read-back verification), atomic file writes, snapshot retention, and the audit-log reader. Integration tests in tests/integration/ exercise the REST API and SSR pages against a live stack.
make test # full test suite (fmt, vet, lint, vuln, race, fuzz, coverage gate, integration)
make test-race # unit tests with race detector
make test-fuzz # native Go fuzzing suite (slug validation & audit log parsing)
make test-coverage-gate # strict coverage verification (fails if statement coverage < 85%)
make test-coverage # HTML/summary test coverage report
make release-ready # full pre-push gate (fmt, vet, lint, vuln, race, compose, build)CI runs three workflows on every PR against
main: lint (golangci-lint, yamllint, actionlint, hadolint, zizmor), test (vet, race-detector tests, build, compose validation), and build-scan-sign (multi-arch build + Trivy gate withvuln,secret,misconfigscanners and SARIF upload). Dependabot keeps gomod, Docker and GitHub Actions dependencies up to date (weekly,chore(deps)commits). The local pre-push gatemake release-ready(fmt, vet, lint, vuln, test-race, compose-config, build, docker-build) mirrors what CI enforces - it is the merge gate (see CONTRIBUTING.md). Onv*tag pushes, build-scan-sign additionally pushes the multi-arch image to GHCR with SBOM, keyless cosign signature, and SLSA provenance.
Each domain managed by the UI gets its own file:
/ui-managed/waf-{domain}.conf
Content example:
# Caddy WAF UI managed - do not edit manually
# domain: api.example.com | mode: On | updated: 2026-07-22T14:00:00Z
coraza_waf {
directives `
Include /etc/caddy/coraza.conf
Include /etc/caddy/owasp-crs/crs-setup.conf
Include /etc/caddy/owasp-crs/rules/*.conf
SecRuleEngine On
Include /etc/caddy/ui-managed/exclusions-api_example_com.conf
SecAuditEngine RelevantOnly
SecAuditLog /data/logs/coraza-audit.log
SecAuditLogFormat JSON
SecAuditLogParts ABCDEFGHIJKZ
`
}The overlay is an inline coraza_waf { ... } block (no named snippet wrapper), directly importable inside site blocks. The # domain: | mode: | updated: header is the machine-readable contract consumed by the domain scanner; the Include path comes from CADDY_UI_INCLUDE_DIR.
Exclusions are written to /ui-managed/exclusions-{domain}.conf. Three operation types are supported: by rule ID, by tag, and by URI + parameter.
# By rule ID
SecRuleRemoveById 941100
# By tag
SecRuleRemoveByTag "attack-xss"
# By URI + parameter
SecRule REQUEST_URI "@beginsWith /api/v1/content" \
"id:9000001,phase:2,pass,nolog,ctl:ruleRemoveTargetById=942100;ARGS:body"
Exclusions are managed on the per-domain exclusions page - a catalog of common OWASP CRS rules plus a custom directive form, submitted via POST /sites/{domain}/exclusions (by rule ID, tag, or URI + parameter).
Written to /ui-managed/ip-rules-{domain}.conf. Processed by Caddy before WAF - zero WAF overhead on blocked traffic.
# Denylist
@ip_deny_api_example_com {
remote_ip 1.2.3.4/32 10.0.0.0/8
}
abort @ip_deny_api_example_com
# Allowlist (if set, all other IPs are denied)
@ip_allow_api_example_com {
not remote_ip 203.0.113.0/24 198.51.100.5/32
}
abort @ip_allow_api_example_comReads (bounded tail) from /data/logs/coraza-audit.log (shared volume, read-only access for the UI). Filterable by:
- Domain
- Rule ID
- Severity
- Action (detected / blocked)
- Time range
Each entry shows the trigger details as columns - timestamp, action (DETECTED / BLOCKED), rule ID, client IP, and URI:
2026-07-22 14:32:11 BLOCKED 941100 203.0.113.7 GET /search?q=<script>
Exclusions are managed from the per-domain exclusions page.
| Control | Implementation |
|---|---|
| AC-3 - Access control | Bearer token auth on all UI routes; Admin API never exposed beyond Docker network |
| AC-4 - Info flow | UI writes only to ui-managed/ volume; base Caddyfile mounted :ro |
| AU-12 - Audit logging | All UI actions logged to stdout in JSON with actor, timestamp, action, domain |
| CSP - Browser content security | Content-Security-Policy on every response (default-src 'self'); assets self-hosted and embedded in the binary (Pico CSS v2.1.1 pinned, no CDN); no inline scripts or styles |
| SC-8 - Transmission security | UI binds to 0.0.0.0:8080 by default; host exposure restricted via compose port mapping, external access via Caddy+mTLS if needed |
| SC-28 - Protection at rest | .env permissions enforced at 600; token never stored in config files |
| SI-4 - Monitoring | Log viewer surfaces WAF detections (implemented); one-click exclusion path discourages disabling (planned) |
| CM-3 - Config change control | Every write creates a snapshot before applying; UI rollback implemented (snapshot restore + reload) |
The UI assumes the Docker network is trusted (same compose stack). If Caddy itself is compromised, the Admin API is accessible - this is a known tradeoff for the hot-reload capability. Mitigations:
- Admin API is localhost-only within Docker (
caddy:2019, not exposed to host) - The UI container has no access to certs, base Caddyfile, or Ansible vault
- All writes are audited and reversible
ui-managed/isolation limits blast radius to runtime WAF overrides only
The only required change to your Ansible roles is:
group_vars/all/caddy.yml
# Enable Admin API when caddy-waf-ui is present on this node
caddy_admin_enabled: "{{ caddy_ui_enabled | default(false) }}"host_vars/{node}.yml (per node that runs the UI)
caddy_ui_enabled: trueCaddyfile.j2 - add one line to each site block:
{{ svc.domain }} {
import /etc/caddy/ui-managed/ip-rules-{{ svc.domain | replace('.', '_') }}.conf
import /etc/caddy/ui-managed/waf-{{ svc.domain | replace('.', '_') }}.conf
...
}Managed blocks never import waf (the overlay embeds its own coraza_waf). The slug is domain.DomainSlug(domain) - lowercase, . β _, * β wildcard, - β _ - and Ansible must pre-create empty placeholders for both files before first boot (a specific import that is missing hard-fails Caddy startup). Exclusions are reached via the Coraza internal Include inside the overlay, never via the Caddyfile. See INTEGRATION.md Β§5.
Ansible never writes to ui-managed/. The volume is owned exclusively by caddy-waf-ui. Subsequent ansible-playbook runs update the base Caddyfile only - they do not affect UI-managed overrides.
Before every write, the UI snapshots the current state:
/backups/{domain}/
βββ 2026-07-22T14-00-00Z.conf
βββ 2026-07-22T13-45-00Z.conf
βββ ... (up to CADDY_UI_BACKUP_KEEP snapshots)
Rolling back:
- Select domain β History tab
- View diff between current and snapshot
- Click Rollback β UI writes snapshot + calls
POST /loadon Caddy Admin API - Snapshot of the pre-rollback state is saved first (rollback is itself reversible)
The product evolution, milestone breakdown (from v1.0.0 foundation to v3.0.0 enterprise IaC automation), and technical controls are tracked in the dedicated ROADMAP.md.
| Horizon | Primary Focus | Reference |
|---|---|---|
| v1.x | Core UI sidecar, NIST hardening, wildcard domains, and operational log rotation | ROADMAP.md#v140--operational-reliability--data-mobility |
| v2.x | Granular Coraza Paranoia Level tuning, CIDR radix matching, security dashboard | ROADMAP.md#v200--granular-waf--coraza-engine-tuning |
| v3.x | Ansible YAML export, Vault integration, and Admin API mTLS authentication | ROADMAP.md#v300--enterprise-iac--automation |
This project follows a coordinated disclosure policy. If you discover a vulnerability, do not open a public issue. See SECURITY.md for reporting instructions, response timelines, and the accepted residual risk on the Caddy Admin API channel.
See CHANGELOG.md for the full version history. The project follows Keep a Changelog and Semantic Versioning.
Contributions are welcome. Read CONTRIBUTING.md before opening a PR.
This project follows Conventional Commits and the Developmi engineering standard.
Copyright Β© 2026 Miguel Lozano | Developmi.
Licensed under the MIT License.
Maintained by: Miguel Lozano | Developmi
- Role: Cloud & Infrastructure Engineer | FinOps & Bare Metal Specialist
- Philosophy: Security is not a feature; it is the baseline.
- Website: developmi.com
- GitHub: Miguel Lozano
- LinkedIn: Miguel Lozano
Β© 2026 Miguel Lozano | Developmi. All rights reserved.