Case study

CaddyUI — A Self-Hosted Control Plane for Caddy

  • Role Creator & maintainer
  • Stack Go 1.27, htmx, Tailwind, SQLite or MariaDB, Caddy
  • Source X4Applegate/caddyui
  • Latest v2.45.1 · September 10, 2026
  • Licence CaddyUI-SAL 1.0: free for personal, non-profit, small-business and internal use; commercial licence only to resell it as a hosted service

Go htmx Tailwind SQLite MariaDB Docker Caddy Fleet DNS-01 Prometheus CrowdSec MCP

65

GitHub stars

70+

Releases since May 2026

0

Open issues

99–100

Lighthouse scores on /login

What CaddyUI is

Caddy is a superb web server and reverse proxy with automatic HTTPS, but everything about it lives in a Caddyfile. CaddyUI puts a clean web interface in front of it: proxy hosts, redirections, certificates, DNS automation and raw routes, with the generated configuration pushed to Caddy through its admin API. It ships as a single Go binary in a scratch-based Docker image that runs as a non-root user, keeps its state in embedded SQLite or MariaDB, and manages one server or a whole fleet of Caddy nodes from the same screen.

Two chapters: the first half of this study is the performance and accessibility wave from spring 2026 that reached a perfect Lighthouse score. The second half is what the project became over the following four months.

What it does today

The feature set as it stands at v2.45, grouped the way the README groups it.

Routing

Proxy hosts with one-click TLS, 301/302/307/308 redirections including path rules and sunset-date 410s, advanced routes from raw Caddyfile or JSON, paste-a-Caddyfile import with a smart classifier, import of a live Caddy config on first run, about 70 per-host options for headers, upstream overrides and buffers, and branded 404/502/503/504 pages.

Certificates and DNS

Custom PEM or file-path certificates, standalone managed DNS-01 wildcards that several servers share, live renewal status per node, certificate export to a directory for other services, and managed DNS that creates the A record on save. Providers: Cloudflare, Porkbun, Amazon Route 53 and Gandi, with SSL import from Porkbun.

Multi-server fleet

One CaddyUI manages any number of Caddy nodes through their admin API, typically over WireGuard or Tailscale. Edge hosts run only Caddy. Fleet sync is a non-destructive, idempotent merge; hosts can be deployed to several nodes or kept node-local; expectations roll back a change that fails its checks.

Access control

Three roles: admin, user and view. Every resource has an owner; groups let a team see each other’s resources; TOTP two-factor authentication; optional Cloudflare Turnstile or reCAPTCHA v3 on login; CSRF tokens on every cookie-authenticated change; a Content Security Policy on generated pages.

Observability

Requests attributed to the node that handled them, ephemeral in-memory runtime log streaming, Prometheus metrics per node and host, opt-in visitor analytics with configurable retention, live upstream and application health checks through Caddy’s admin API, CrowdSec request protection, and an activity log of every change.

Operations

Embedded SQLite or a MariaDB backend with a batched migration between them, automatic and manual configuration snapshots, SMTP and webhook notifications for certificate expiry and upstream health, update notices, light, dark and four accent themes, and an installable PWA. Settings are eight focused pages, each saving only its own keys.

AI assistant

Opt-in chat that answers Caddy, TLS and DNS questions and fills in hosts from a plain-English description using tool calling. Bring your own backend: local Ollama, Ollama Cloud, Anthropic Claude, or any OpenAI-compatible API, with a custom system prompt and no data sent anywhere unless enabled.

How it is built

A single Go binary with embedded templates and assets, talking to Caddy exclusively through the HTTP admin API: no SSH, no direct config-file edits. Docker images target linux/amd64 with SBOM and provenance attestations and run as a non-root user; native archives cover amd64 and arm64.

The eleven-version Lighthouse wave

Users kept saying “the page feels slow” and Lighthouse kept scoring the login page somewhere in the thirties. Rather than one big rewrite, each release from v2.12.38 to v2.12.48 attacked exactly one Lighthouse audit, measured on Google PageSpeed Insights against a self-hosted install behind a residential connection.

VersionChangeMobileDesktop
~v2.12.37Baseline~30–40~50–60
v2.12.38Cache-Control: max-age=86400 on /static/*~40~70
v2.12.39Self-host Inter font and htmx; service-worker rewrite that purges stale caches per release~50~80
v2.12.40Preload Inter, preconnect to the Tailwind CDN~5591
v2.12.41Move 28 KB of inline JS to /static/app.js, cached across navigations~55~95
v2.12.42Preload app.js; defer non-critical version and AI status calls~55~96
v2.12.43Precompiled auth.css for the login page; Cloudflare Turnstile replaces reCAPTCHA76~99
v2.12.44–45Section anchor pills on the proxy-host form, ordered by frequency, form reordered to match7699
v2.12.46aria-current, aria-label on icon buttons, skip-to-content link7699
v2.12.47Label and input associations on every unauthenticated page7699
v2.12.48<meta name="description"> for SEO 1007699

Final desktop metrics: FCP 0.5 s, LCP 0.9 s, TBT 0 ms, CLS 0.001, Speed Index 0.6 s. The last performance point is bandwidth-bound: assets have to physically reach Google’s test servers from a residential ISP. Mobile stays at 76 because Lighthouse throttles the CPU four times and simulates a 1.6 Mbps connection; real users on a second visit see far better.

Headline performance wins

1. Precompiled CSS for the login page (v2.12.43)

PageSpeed always lands on /login for anonymous users. That page loaded 124 KB of the Tailwind JIT runtime, over 600 ms of JavaScript parse on a throttled phone, to style 63 unique classes. A scoped Tailwind config now scans the five unauthenticated templates and emits a 24 KB minified auth.css, about 5 KB gzipped. Authenticated pages keep the runtime because they use over 300 classes. Mobile jumped from 41 to 76 in one release.

2. Service-worker rewrite (v2.12.39)

The old worker had a hard-coded cache name that never changed, so after every docker pull users kept receiving the previous release’s stylesheet. The rewrite stops caching static assets in the worker, stops caching HTML because CaddyUI is online-only, and bumps the cache name each release so activation purges stale state automatically.

3. Externalising inline JavaScript (v2.12.41)

A 600-line inline script in the layout template was re-parsed on every page load. Moving it to /static/app.js lets the browser parse it once per session. Total Blocking Time fell from 2,230 ms to 0 ms.

4. Self-hosting the font and htmx (v2.12.39)

The Inter variable font and htmx.min.js are downloaded at Docker build time and embedded in the Go binary with embed.FS, removing three CDN round trips on cold loads. Since v2.27 every UI asset is served from the binary, so the interface works on networks that block third-party origins entirely.

5. Cloudflare Turnstile instead of reCAPTCHA

reCAPTCHA cost roughly 927 ms of JavaScript execution on desktop and 3.6 s on mobile. Turnstile does the same job in 150–500 ms with no quota and no tracking, and the swap is a settings toggle.

Accessibility deep dive

The accessibility score was capped between 86 and 92 by three gaps the automated checker can detect. Two releases closed all of them.

aria-current

The active navigation link was visual only
Screen readers could not tell which page was current. The active link now carries aria-current="page", and decorative icon spans are hidden from assistive technology.

aria-label

Icon-only controls had tooltips, not names
Five buttons relied on title, which is a tooltip rather than an accessible name. Theme toggles, search, the server picker and the account menu now have explicit labels.

skip link

Keyboard users had to tab through the whole sidebar
A visually hidden link at the top of the page becomes a focused pill and jumps straight to the main content.

for / id

Labels were not associated with inputs
The actual score-blocking gap. Labels matched inputs visually but lacked for and id pairs. Fixed across login, setup, password reset and invite pages, with autocomplete hints so password managers offer to save credentials.

From v2.12 to v2.45: what changed since

The Lighthouse wave ended on April 30, 2026. Between mid-May and September 10 the project shipped more than seventy releases, and its scope moved from “a nicer way to edit one Caddyfile” to operating a fleet of Caddy nodes with the safety rails an operations team expects.

  • May · v2.15 — UI overhaulToast notifications, sparklines, selectable themes, certificate expiry bars and a quick-mode form, with cache-busted static assets so a new theme loads immediately after an upgrade.
  • July · v2.16 — DNS automation grows upMultiple DNS credential profiles, DNS-01 certificate support, bulk certificate assignment across proxy hosts, dashboard recommendations, and API token-scope hardening so the community CaddyUI-MCP server can drive the REST API with the narrowest scope that fits.
  • July · v2.17 — Managed wildcard certificatesStandalone managed wildcard certificates that several servers share, enforced reuse instead of duplicate issuance, and live renewal visibility.
  • July · v2.18 to v2.20 — Enterprise operationsA redesigned operations UI with semantic status colours, a MariaDB backend with a safe batched migration from SQLite, a guided first-run journey that reads real configuration state, a Hostname → Upstream → Policy → Review wizard for publishing a service, and named cleanup of unused certificates.
  • August · v2.21 to v2.24 — Fleet plumbingPer-server native access logs, CrowdSec request protection with live connection tests, correct client IPs behind Cloudflare, idempotent fleet configuration sync, Amazon Route 53 managed DNS, and detailed Prometheus metrics.
  • August · v2.25 to v2.26 — ObservabilityEvery request attributed to the Caddy node that handled it, ephemeral structured server-log streaming, managed-certificate lifecycle states driven by Caddy’s own events, and a live Caddyfile preview while editing a route.
  • August · v2.27 to v2.35 — HardeningAll UI assets served from the binary, configurable status monitoring, CSRF tokens on every cookie-authenticated change, deterministic generated config with a Content Security Policy, indexed analytics aggregation, node-local hosts that opt out of fleet sync, and a Go 1.27 toolchain.
  • September · v2.36 to v2.45 — Safety railsExpectations that run post-apply checks and roll back automatically, a live TLS probe for file-path certificates, certificate export to a directory after every renewal for services such as mail, validation against Caddy before a change is saved with a visible failed-sync banner, analytics retention that actually runs with reclaimable storage, Settings split into eight pages, and Gandi managed DNS.

Fleet, certificates and safety

Caddy Fleet

Multiple Caddy nodes are managed as an inventory with health, endpoint, management policy and last contact. A change can be deployed to several nodes at once, and hosts can be marked node-local so a sync never copies them.

Certificates that travel

Managed ACME, pasted PEM and file-path certificates all sync across the fleet. A wildcard obtained once can be shared, and CaddyUI can export a renewed certificate to a directory so a mail server picks it up without certbot.

Refuse what Caddy would reject

Proxy hosts, redirections and certificates are validated against Caddy before they are saved. When a sync fails for any reason, an amber banner names the node and the error on every page until the next sync succeeds.

Expectations with rollback

After a change is applied, CaddyUI checks that the site still answers the way you told it to expect. If it does not, the previous configuration is restored automatically.

Advanced config, understood

A host’s Advanced config accepts a reverse_proxy { … } block that merges with the generated handler, respells JSON-style option names into Caddyfile ones, and explains bare sub-directives instead of echoing Caddy’s error.

Storage that stays bounded

Raw analytics events are pruned in small batches on a schedule, retention is configurable, and SQLite space can be reclaimed from the UI. One production database had grown to 22 GB before this shipped.

Lessons

One audit per release

Small, measurable releases beat one big rewrite. Each performance version had a single target, the changelog explains every step, and nothing regressed because every change was small enough to reason about. The same habit carried into the fleet era, where most releases fix one reported case end to end.

Issues become features

The best roadmap is the issue tracker. A file-path certificate that existed on the host but not in the container became pre-save validation and a failed-sync banner. A Nextcloud host with JSON-style buffer names became automatic respelling. Gandi support came from a discussion thread.

Reviews on every pull request

Automated review caught a stale instruction. Pull requests are reviewed by Claude in CI; the first thing it caught was a message that told users to pick an “Override” action removed many versions earlier. The wording in the app, the docs and the release note were fixed in v2.45.1.

AI assistance, disclosed

Built with Claude, released by a human. The README states it plainly: Claude helps with debugging, implementation, review and documentation, every change is reviewed and tested by the maintainer before release, and no credentials, database contents or user data are ever shared with the model.

Install and upgrade

docker pull applegater/caddyui:latest
docker compose pull && docker compose up -d

Migrations run automatically on start-up, and Docker images include SBOM and provenance attestations. Tagged releases also ship native Linux binaries for amd64 and arm64 hosts for a systemd install without Docker.