A web management interface for Caddy. It edits your Caddyfile, validates every change through Caddy itself before anything is written, keeps a versioned history with one-click rollback, and shows upstream health, metrics, certificates and live logs.
Ships as a single ~23 MB container: a Go binary with the frontend embedded, on a distroless base, running as a non-root user.
Certificates
- Caddy's internal CA, Let's Encrypt (staging or production), or your own upload
- ACME DNS-01 via a DNS provider, for hostnames that do not point at this server
- Per-site DNS pre-checks, so an option that cannot succeed is not offered
- Expiry inventory assembled from certificate storage and live TLS handshakes
Configuration
- Visual editor for sites, routes and reverse-proxy upstreams
- Full Caddyfile editor with syntax highlighting and inline error markers
- Every change is diffed against what is running before it is applied
- Versioned history with rollback; append-only audit log of who changed what
- Automatic revert if a change is not confirmed (see Safety net)
Operations
- Upstream health with 24-hour uptime, from Caddy's
/reverse_proxy/upstreams - Request rate, status-code mix and latency percentiles from
/metrics - Certificate inventory with expiry warnings
- Live access-log streaming with filtering
Access control
- Argon2id passwords, opaque server-side sessions, CSRF protection
- Administrator and read-only roles, enforced server-side
- Optional TOTP two-factor with recovery codes
- Per-account and per-IP login rate limiting with lockout
The admin API can rebind ports, expose the filesystem through a file_server
route, and stop the process. This UI is therefore built around the assumption
that a bad change is the thing most likely to hurt you.
Every mutation — visual edit, raw edit, or rollback — goes through one pipeline:
- Parse locally, for an immediate error with a line number
- Validate by POSTing to Caddy's
/adapt; if Caddy rejects it, nothing is written to disk - Diff against the running configuration and show it to you
- Snapshot the current config into the version history
- Write atomically — temp file,
fsync,rename— so a crash can never leave a half-written Caddyfile - Load it, then read the config back to confirm it actually took effect
- Roll back automatically if any step after the write fails
A change can pass every check above and still lock you out — a redirect loop, a bad TLS policy, or a route that swallows the UI's own hostname. So after a successful apply the UI starts a countdown (default 60 s). Click Keep this change to make it permanent; do nothing and the previous configuration is restored automatically.
If you have locked yourself out, you will not see the banner — which is exactly
when the timer matters. Set SAFETY_NET_SECONDS=0 to disable it.
git clone <this repo> caddy-ui && cd caddy-ui
mkdir -p config data logs
# Start from your existing config, or write a new one
cp /etc/caddy/Caddyfile config/Caddyfile
# The UI container runs as uid 65532 and must be able to replace the Caddyfile
sudo chown -R 65532:65532 config data logs
docker compose up -d --build
docker compose logs caddy-ui | grep -A6 'First run' # the generated passwordYour Caddyfile must expose the admin API to the other container. Caddy binds
it to localhost by default, which is unreachable across a container boundary:
{
admin 0.0.0.0:2019
}This is safe here because docker-compose.yml puts the admin API on an
internal: true network with no route off the host, and does not publish port
2019. Do not publish it.
The UI is not published to the host either. To reach it during setup, uncomment
the ports: block in docker-compose.yml, or better, proxy it through Caddy —
see Exposing the UI.
For a Caddy already running under systemd:
mkdir -p data
sudo chown -R 65532:65532 data
sudo chown 65532 /etc/caddy # so the UI can replace the Caddyfile
docker compose -f docker-compose.external.yml up -d --buildThis uses host networking so the container can reach 127.0.0.1:2019, and binds
the UI to 127.0.0.1:8080 — reachable over an SSH tunnel, not from the network.
Note on
--resume. The stockcaddy.servicerunscaddy run --config /etc/caddy/Caddyfilewithout--resume, so changes made through the admin API alone are discarded on restart. This UI writes the Caddyfile itself, so its changes do survive. Only if you also intend to use the API directly do you needcaddy-api.serviceor a--resumeflag.
All settings are environment variables.
| Variable | Default | Meaning |
|---|---|---|
LISTEN_ADDR |
:8080 |
Address the UI binds to |
CADDY_ADMIN_URL |
http://127.0.0.1:2019 |
Caddy's admin API |
CADDYFILE_PATH |
/etc/caddy/Caddyfile |
The file being managed |
CADDY_DATA_DIR |
(unset) | Caddy's storage dir, mounted read-only, for the certificate inventory |
LOG_DIR |
(unset) | Directory of access logs to tail |
DB_PATH |
/data/caddy-ui.db |
SQLite database |
SAFETY_NET_SECONDS |
60 |
Confirmation window; 0 disables |
SECURE_COOKIES |
false |
Set true once served over HTTPS |
TRUSTED_PROXY |
false |
Honour X-Forwarded-For — see below |
SESSION_IDLE_TTL |
2h |
Idle session timeout |
SESSION_ABSOLUTE_TTL |
24h |
Hard session lifetime |
RETENTION_DAYS |
30 |
How long metrics and audit history are kept |
LOG_LEVEL |
info |
debug, info, warn, error |
Configuration is validated at startup; the process refuses to start on a bad admin URL or an unwritable Caddyfile, and reports every problem at once.
Never expose this to the internet over plain HTTP. Anyone who reaches it can reconfigure every site on the host.
Publish it through Caddy itself, with TLS and an IP allowlist:
caddy-ui.example.com {
# Restrict to your network; delete this block if you use another
# authentication layer in front.
@blocked not remote_ip 10.0.0.0/8 192.168.0.0/16
respond @blocked 403
reverse_proxy caddy-ui:8080
}Then set SECURE_COOKIES=true and TRUSTED_PROXY=true.
TRUSTED_PROXYmakes the UI believeX-Forwarded-For. Enable it only when it genuinely sits behind a proxy you control — otherwise any client can forge the header and evade login rate limiting.
On first start the UI creates an admin account with a random password and
prints it once to the container log. There is no default password. You must
change it before anything else becomes reachable.
docker compose logs caddy-ui | grep -A6 'First run'HTTP-01 and TLS-ALPN-01 both require the certificate authority to connect back to this server on port 80 or 443. That fails whenever the hostname resolves somewhere else -- behind a CDN or proxy, on a private network -- and it cannot issue wildcards at all. DNS-01 proves ownership with a temporary DNS record instead, so it works in all of those cases.
Caddy's DNS providers are compiled-in modules, so the stock image cannot do it. Build one that can:
docker build -f Dockerfile.caddy -t caddy-dns:latest .Add providers with further --with github.com/caddy-dns/<provider> lines; the
list is at https://github.com/caddy-dns. The build fails loudly if a module is
missing rather than surfacing as a puzzling config error later.
Then create a scoped API token -- for Cloudflare, Zone:DNS:Edit on the zones
you serve, nothing broader -- and put it in .env:
CLOUDFLARE_API_TOKEN=...
The token is never entered through the web interface. The generated config
references it as {env.CLOUDFLARE_API_TOKEN}, which Caddy resolves in-process
at load time. Verified: the placeholder survives adaptation unexpanded, so the
value appears in neither the Caddyfile, the diff, the version history, nor the
JSON sent to the browser.
One *.example.com certificate covers every subdomain, turning N issuances into
one — worth the most with DNS-01, where each issuance costs a propagation wait.
Certificates → Issue a wildcard… creates the site and moves the subdomains
it covers onto it.
Three rules decide what a wildcard is worth, all measured against Caddy 2.11.4 rather than taken from documentation, because each one changes what the UI does:
- Subdomains with their own site block do not get their own certificate once
a wildcard covering them is being managed. A config with
*.wild.testandfoo.wild.testproduced exactly one certificate,wildcard_.wild.test.crt. This is a runtime decision in Caddy 2.10+, and it is not visible in the adapted JSON —foo.wild.teststill appears there with an automation policy of its own, which would suggest the opposite. - The apex is not covered.
wild2.testwas issued its own certificate alongside the wildcard, so the dialog offers to put the apex on the same DNS challenge in the same step. - A wildcard matches exactly one label.
deep.sub.wild2.testwas also issued its own certificate, so those sites are reported as uncovered rather than left to fail quietly.
The wildcard site is generated with abort: it exists to own a certificate, not
to serve anything, and any subdomain that should answer has its own block.
Things discovered by testing against real Caddy instances, which explain some of what the UI does.
HTTP metrics are off by default. Caddy exports only admin metrics until the
metrics option is set. The dashboard detects this and offers a button to enable
it, rather than showing empty charts. The option has three spellings and none
works everywhere — 2.6 accepts only servers { metrics }, 2.7 added the global
metrics and deprecated the nested form, 2.9 added per_host — so the UI
offers each to /adapt in turn and uses the best one that server accepts.
Metrics are registered lazily, and a reload resets them. Caddy creates the
caddy_http_* collectors the first time each server handles a request. With
metrics correctly enabled and no traffic, /metrics contains not one
caddy_http_ line; after three requests it contains 52 (measured on 2.11.4,
same behaviour on 2.6.2). So an empty exposition does not mean the feature
is off, and the UI reads the config to decide that — otherwise applying any
change through this UI, which reloads, would make the dashboard claim metrics
had been switched off.
The host label exists only with per_host. Without that option no series
carries one, and since nearly every request is served by the subroute handler,
the handler breakdown is a single meaningless bar. With it, traffic can be
attributed per site, which is what the dashboard plots when it is available.
Status codes live on caddy_http_response_duration_seconds_count, not on
caddy_http_requests_total, and the error count is derived from 5xx responses
because Caddy exposes no error counter.
Access logging needs two things, and the UI's one-click action does both: a
global log block choosing the output file, and a log directive inside each
site. With only the global block, Caddy redirects its own diagnostics and
records no requests at all.
Log files are created mode 0600. Since Caddy and the UI run as different
users, the generated config includes mode 644. Caddy 2.7+ honours this; 2.6
silently ignores it, so on 2.6 you must make the log directory readable by uid
65532 yourself.
Caddy has no endpoint that lists certificates. The inventory is assembled from two independent sources — a read of the certificate store and a live TLS handshake against each configured host — and the UI labels which is which rather than merging them into a list that hides the difference.
The toolchain runs in Docker, so nothing has to be installed on the host:
./go.sh build ./...
./go.sh test ./...
./go.sh vet ./...Frontend:
cd web && npm install && npm run dev # proxies /api to localhost:8080Unit tests are hermetic. Integration tests start a throwaway Caddy on a random
port in a temp directory and skip if the caddy binary is absent — they never
touch a running server. To include them:
docker run --rm -v "$PWD":/src -v /usr/bin/caddy:/usr/bin/caddy:ro \
-w /src -e CGO_ENABLED=0 golang:1.25-bookworm go test ./...The Caddyfile parser additionally has a differential test asserting its output
matches real caddy fmt byte for byte, and a fuzz target for round-trip
stability:
./go.sh test ./internal/caddyfile/ -run=XXX -fuzz=FuzzFormatStability -fuzztime=60sCaddy's own parser discards comments, because it only ever needs the adapted JSON. A tool that writes the file back has to preserve them. This package keeps every token's original text and every comment, so regions you did not edit round-trip byte for byte, and directives the visual editor does not understand are preserved verbatim rather than silently dropped.
cmd/caddy-ui/ wiring and graceful shutdown
internal/
caddyfile/ comment-preserving lexer, parser, printer
model/ syntax tree <-> sites and routes, for the visual editor
apply/ the single path by which config reaches Caddy
caddyapi/ admin API client
store/ SQLite: users, sessions, versions, audit, telemetry
auth/ argon2id, sessions, CSRF, RBAC, TOTP, lockout
certs/ metrics/ logs/ certificate, metric and log collection
httpapi/ REST + SSE handlers
web/ the embedded frontend bundle
web/ React + TypeScript + Vite source
MIT