caddy/ui: A Web Interface for Caddy, Built in Conversation with Claude
I've been running Caddy as my reverse proxy for a while now. It's excellent — automatic TLS, clean config syntax, fast. But managing it meant dropping into SSH, editing the Caddyfile by hand, and reloading. Not terrible, but not great either when you're doing it for the fifteenth time.
I wanted a web UI. There are a few out there, but none of them felt like what I was after. So I built one.
What started as a single conversation with Claude became caddy/ui — a full-stack management interface for Caddy that runs as two Docker containers alongside your existing setup.
What it does
caddy/ui exposes six tabs:
Dashboard shows you live server status, TLS state, a summary of your server blocks, upstream health, and Caddy process info — version, uptime, heap usage, and last reload time. Clicking a server block navigates directly to Routes filtered to that block.
Caddyfile Editor is a CodeMirror-powered editor with nginx syntax highlighting (close enough for Caddyfile). It validates before saving using Caddy's own /adapt API, runs caddy fmt on save, sorts site blocks automatically (public → internal → http), and keeps a full version history with inline preview and one-click rollback.
Route Manager shows all reverse proxy routes across every server block — not just srv0. Each route gets a live health dot sourced from Caddy's upstream pool API, with TCP fallback for routes not yet in the pool. There's uptime tracking, search/filter, clickable domain and upstream links, edit-in-place for UI-managed routes, and per-route notes. Caddyfile-managed routes show a notice instead of edit fields, since editing them in the UI would lose their formatting.
TLS Certificates lists every cert Caddy is managing with expiry dates, days remaining, and status. Sortable by domain, expiry, or days. Orphaned certs (no longer in the Caddyfile) are flagged and can be deleted. Root CA download comes directly from Caddy's /pki/ca/local admin endpoint.
Access Logs tails the last 200 lines of your access log with live SSE streaming, keyword search, and ERROR/WARN/INFO level filters. Log configuration (enable/disable, format, level, roll settings) is editable directly from the UI — it modifies the global Caddyfile block and reloads Caddy automatically.
Metrics shows request counts, RPS, average response time, status code breakdown, and p50/p95/p99 percentiles. All powered by Caddy's built-in Prometheus endpoint. The tab also lets you enable or disable the metrics directive in your Caddyfile without editing it manually.
Architecture
The backend is Node.js with Express. It talks to Caddy's admin API on port 2019, reads and writes the Caddyfile from a shared volume, and handles all the business logic. The frontend is React with Vite, served via Nginx, with all /api/* requests proxied to the backend.
One deliberate design decision: your Caddyfile stays the source of truth. The UI reads from it and writes to it, but it never tries to own it. Routes you manage through the UI get a @id annotation so the backend can find and update them. Routes defined by hand in the Caddyfile are visible in the Route Manager but read-only — editing them is a Caddyfile job.
A few things that came out of building this
Caddy's admin API is more useful than I expected. Validation is handled by POST /adapt?adapter=caddyfile — no need to shell out to the caddy binary for that. The upstream pool is available at GET /reverse_proxy/upstreams, which gives you Caddy's own view of upstream health including failure counts. Root CA download is GET /pki/ca/local. I'm using all of these instead of filesystem reads or Docker socket access wherever possible.
The Docker socket is a liability. An earlier version used docker exec to run caddy validate and caddy version. That's a lot of surface area to mount just for two commands. Both are now handled differently — validation via the admin API, the Caddy binary bundled in the backend image via a multi-stage build. No socket required.
Caddyfile-managed routes need special handling. When you define a route in the Caddyfile, Caddy's JSON config represents it without an @id. When you create a route through caddy/ui, the backend assigns one. So hasId is the reliable signal for “this route is UI-managed and editable.” Routes without an ID get a notice in the edit modal instead of form fields.
The upstream health logic is a hybrid. Caddy's pool API is the primary source — if Caddy has seen traffic to an upstream, it tracks it. But for routes with no recent traffic (or routes defined only in the Caddyfile), the upstream won't appear in the pool. Those fall back to a direct TCP connect. Both paths feed the same rolling uptime tracker.
Getting started
services:
caddy:
image: caddy:latest
container_name: caddy
environment:
- CADDY_LOG_PATH=/var/log/caddy/access.log
- DOMAIN=example.com
- EMAIL=you@example.com
- TZ=America/New_York
volumes:
- /docker/caddy/Caddyfile:/etc/caddy/Caddyfile
- /docker/caddy/data:/data
- /docker/caddy/config:/config
- /docker/caddy/logs:/var/log/caddy
networks:
- caddy-ui
caddy-ui-backend:
image: zackwag/caddy-ui-backend:latest
container_name: caddy-ui-backend
ports:
- 9876:3001
environment:
- TZ=America/New_York
# Optional -- leave unset to disable auth
- CADDY_UI_USER=admin
- CADDY_UI_PASSWORD=yourpassword
- JWT_SECRET=your-long-random-secret
volumes:
- /docker/caddy/Caddyfile:/etc/caddy/Caddyfile
- /docker/caddy/logs:/var/log/caddy
- /docker/caddy-ui:/etc/caddy-ui
- /docker/caddy/data:/data/caddy
networks:
- caddy-ui
depends_on:
- caddy
caddy-ui-frontend:
image: zackwag/caddy-ui-frontend:latest
container_name: caddy-ui-frontend
ports:
- 9877:80
networks:
- caddy-ui
depends_on:
- caddy-ui-backend
networks:
caddy-ui:
driver: bridge
One thing worth calling out: admin 0.0.0.0:2019 needs to be set in your Caddyfile global block, not as an environment variable. The CADDY_ADMIN env var doesn't do what you might expect.
{
admin 0.0.0.0:2019
email {$EMAIL}
}
All backend environment variables have sensible defaults. The only ones you need to set are auth credentials if you want authentication, and TZ. Everything else works out of the box.
The code
The source is on GitHub at zackwag/caddy-ui. Images are on Docker Hub at zackwag/caddy-ui-backend and zackwag/caddy-ui-frontend.