Skip to content

Repository files navigation

caddy-ui

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.


What it does

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

How it keeps your server up

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:

  1. Parse locally, for an immediate error with a line number
  2. Validate by POSTing to Caddy's /adapt; if Caddy rejects it, nothing is written to disk
  3. Diff against the running configuration and show it to you
  4. Snapshot the current config into the version history
  5. Write atomically — temp file, fsync, rename — so a crash can never leave a half-written Caddyfile
  6. Load it, then read the config back to confirm it actually took effect
  7. Roll back automatically if any step after the write fails

Safety net

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.


Quick start

Option A — Caddy and the UI both in Docker (recommended)

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 password

Your 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.

Option B — the UI only, managing a Caddy on the host

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 --build

This 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 stock caddy.service runs caddy run --config /etc/caddy/Caddyfile without --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 need caddy-api.service or a --resume flag.


Configuration

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.


Exposing the UI

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_PROXY makes the UI believe X-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.

First sign-in

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'

DNS-01 certificates

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.

Wildcard certificates

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.test and foo.wild.test produced 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.test still appears there with an automation policy of its own, which would suggest the opposite.
  • The apex is not covered. wild2.test was 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.test was 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.

Notes on Caddy's behaviour

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.


Development

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:8080

Tests

Unit 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=60s

Why a hand-written Caddyfile parser

Caddy'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.


Architecture

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

Licence

MIT

About

Web management interface for Caddy: visual and raw Caddyfile editing with validation and rollback, TLS certificates (internal CA, Let's Encrypt, ACME ▎ DNS-01), upstream health, metrics and live log streaming. Ships as a single 23 MB distroless container.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages