CaddyUI v2.12.48 — Perfect Lighthouse: 99 / 100 / 100 / 100
After an eleven-version perf + a11y wave (v2.12.38 → v2.12.48), CaddyUI's /login page hits Lighthouse Performance 99, Accessibility 100, Best Practices 100, SEO 100 on Google's PageSpeed Insights — measured against a residential-ISP install, not a global CDN. Mobile sits at 76 on the same simulated 4× CPU + Slow 4G that drags every site down. Self-hosted everything where it makes sense, smarter service worker, Cloudflare Turnstile (replacing reCAPTCHA), full a11y audit with label↔input pairing, skip-to-main-content link, aria-current navigation, and meta descriptions for SEO.
📈 The eleven-version perf + a11y wave
Each version of v2.12.38 → v2.12.48 attacked one specific Lighthouse audit. Score progression on PageSpeed mobile / desktop:
| Version | Change | Mobile | Desktop |
|---|---|---|---|
| ~v2.12.37 | Baseline | ~30–40 | ~50–60 |
| v2.12.38 | Cache-Control: max-age=86400 on /static/* | ~40 | ~70 |
| v2.12.39 | Self-host Inter font + htmx, service-worker rewrite (auto-purges stale caches per release) | ~50 | ~80 |
| v2.12.40 | Preload Inter font, preconnect cdn.tailwindcss.com | ~55 | 91 |
| v2.12.41 | Externalize 28 KB inline JS to /static/app.js (cached across navigations) | ~55 | ~95 |
| v2.12.42 | Preload app.js, defer non-critical /api/version-check + /api/ai/status | ~55 | ~96 |
| v2.12.43 | auth.css for /login (drops Tailwind CDN there) + Cloudflare Turnstile (replaces reCAPTCHA) | 76 | ~99 |
| v2.12.44 | Section anchor pills on the proxy host form, ordered by frequency | 76 | 99 |
| v2.12.45 | Physical reorder of form sections to match pill priority | 76 | 99 |
| v2.12.46 | aria-current, aria-labels on icon buttons, skip-to-main-content link | 76 | 99 |
| v2.12.47 | Label ↔ input associations on every unauth page (for=/id=) | 76 | 99 |
| v2.12.48 | <meta name="description"> for SEO 100 | 76 | 99 |
All four scores end at 100 except Performance which sits at 99 because the last point is bandwidth-bound on Lighthouse's simulated network — the assets have to physically reach Google's test servers from a residential ISP, and that round-trip dominates the final point.
🎯 Headline wins (the ones that actually moved scores)
1. auth.css for /login (v2.12.43) — biggest single jump
PageSpeed always lands on /login for unauth users. That page was loading 124 KB of cdn.tailwindcss.com JIT runtime — 600+ ms of JS parse on a 4× CPU-throttled mobile, dominating LCP — to style only 63 unique classes.
Fix: precompiled CSS for unauth pages only. A scoped tailwind.unauth.config.js scans the 5 unauth templates plus layout.html and emits a 24 KB minified auth.css (~5 KB gzipped). layout.html splits delivery by {{if .User}}: authenticated pages still use the CDN runtime (300+ classes need it), unauth pages get the precompiled version. Mobile /login jumped from 41 → 76 in one release.
2. Service worker rewrite (v2.12.39) — silent stale-cache footgun
The v2.11.15 → v2.12.38 service worker had a hardcoded cache name caddyui-v1 that never bumped. After every docker pull, users kept getting old /static/app.css from cache because the SW had no invalidation. The rewrite drops /static/* SW caching entirely (the new HTTP Cache-Control header does the job correctly), drops HTML page caching (CaddyUI is online-only), and bumps the cache name per release so activate auto-purges stale state.
3. Externalize inline JS to /static/app.js (v2.12.41) — TBT 2,230 ms → 0 ms
The 600-line inline <script> block in layout.html (update badge, dropdown menus, command palette, AI chat, keyboard chord nav, etc.) was being re-parsed on every page load. Moved to /static/app.js — same defer behaviour, but now cached across navigations. Browsers parse it once per session. Total Blocking Time crashed from 2,230 ms to 0 ms.
4. Self-host Inter font + htmx (v2.12.39)
The Inter variable woff2 (340 KB) and htmx.min.js (48 KB) used to come from fonts.gstatic.com and unpkg.com respectively. Now downloaded at Docker build time (curl → web/static/) and embedded into the binary via Go's embed.FS. Saves three CDN round-trips on cold loads, plus the page works on networks that block any of those origins.
5. Cloudflare Turnstile replacing Google reCAPTCHA (settings flip)
reCAPTCHA was costing ~927 ms desktop / ~3.6 s mobile of JS execution on /login. Turnstile does the same job in 150–500 ms. Free, no quota, no Google tracking. CaddyUI has had Turnstile support since v2.5.0; the swap is a Settings toggle, not a code change, but it's the single biggest mobile-perf lever.
♿ Accessibility deep-dive — getting to 100
Lighthouse a11y was capped at 86 → 92 because of three specific gaps the automated checker can detect. v2.12.46 + v2.12.47 closed all three:
aria-current="page" on the active nav link (v2.12.46)
The "you are here" indicator was visual-only (background + brand-colored icon). Screen readers couldn't tell which page the user was on. Added aria-current="page" to the active <a> in the navItem template. Also marked the icon span aria-hidden="true" so AT doesn't read decorative SVG content.
aria-label on icon-only interactive elements (v2.12.46)
Five buttons / links with only an icon (no visible text) had just title= attributes. title is a tooltip, not the accessible name. Added aria-label to:
- Mobile theme toggle
- Desktop theme toggle
- Top-bar Search icon link
- Top-bar server picker dropdown
- Top-bar account menu
Skip-to-main-content link (v2.12.46)
Keyboard users tabbing from the URL bar had to traverse the entire sidebar nav before reaching page content. Added a sr-only focus:not-sr-only link at the top of <body> that becomes a brand-colored pill on focus and jumps straight to <main id="main-content">. Both authenticated (sidebar) and unauth (centered card) layouts get the same anchor target.
Label ↔ input associations on every unauth page (v2.12.47)
This was the actual Lighthouse-blocking gap. The <label> tags existed and visually matched their inputs, but had no for= / id= pairing — so AT (and Lighthouse's a11y tree analyzer) saw the inputs as unlabeled. Fixed across login, setup, forgot_password, reset_password, and accept_invite with consistent ID naming (page-prefixed to avoid collisions). Bonus: while editing each input, also added the right autocomplete attribute (email / current-password / new-password) so password managers reliably offer to save credentials.
📱 Mobile vs desktop — what's still bottlenecked
Desktop hits 99. Mobile sits at 76. The 23-point gap is structural, not a bug:
- Lighthouse mobile is 4× CPU-throttled — every JS parse / paint / hydration takes 4× longer than real devices. The Tailwind CDN runtime on authenticated pages alone costs ~600 ms on the simulator; a real phone parses it in 150 ms.
- Network simulation is 1.6 Mbps — fast enough for desktop comfort, slow for mobile. The Inter variable font (340 KB) eats ~1.7 s of LCP just downloading.
- Residential-ISP TTFB — Google's test datacenters are hundreds of milliseconds away from a home server. A CDN-backed install (Cloudflare in front) would shave ~1 s off every metric.
Real-user mobile experience is much better than 76 — especially after the first visit when assets are cached. Lighthouse measures cold-load worst case, not warm-cache typical case.
📦 Earlier in v2.12 — UX cycle, AI assistant, Managed DNS
The perf wave was layered on top of an existing v2.12 cycle that was already substantial. Briefly:
- ⌘K command palette (v2.11.5) — global resource search across every proxy host, redirection, raw route, and certificate.
- Bulk multi-select + drag-to-reorder (v2.11.6 → v2.11.11) — every list page has bulk Enable / Disable / Delete, plus row drag handles on
/proxy-hostsand/redirection-hosts. - Multi-provider AI assistant (v2.12.36) — chat panel that supports Ollama (local), Ollama Cloud, Anthropic Claude, and any OpenAI-compatible API. Same auto-fill tool calling, conversation memory, and custom system prompt across every backend.
- Managed DNS on redirections (v2.12.2) — closed the long-standing gap where redirect hosts had no DNS plumbing.
- Wildcard DNS-01 cert auto-issuance (v2.11.19) — type
*.example.comin Domains and CaddyUI emits the matchingtls.automation.policiesusing your stored Cloudflare token. - Carbon Orange theme (v2.12.22 → .27) — alternative palette with cross-device sync via per-user DB column.
- Section anchor pills on the proxy host form (v2.12.44 + .45) — 14 pills ordered by frequency, with the form physically reordered to match.
Full per-version detail in the CHANGELOG.md.
📦 Upgrade
docker compose pull && docker compose up -d
Or in Portainer: Recreate → enable Re-pull image. Migrations run automatically on startup.
caddyui-v1 SW may serve stale assets even after the pull. Once: F12 → Application → Service Workers → Unregister next to your CaddyUI domain, then Ctrl+Shift+R. The new SW (v2.12.39+) auto-purges old caches per release, so this is needed only once.
Multi-arch on Docker Hub (linux/amd64 + linux/arm64, scratch base, non-root UID 10001):
# pinned to this exact release (recommended)
docker pull applegater/caddyui:v2.12.48
# rolling tags — all four point at the same image right now
docker pull applegater/caddyui:latest
docker pull applegater/caddyui:stable
docker pull applegater/caddyui:preview
:latest and :stable retag here, jumping from v2.12.40 → v2.12.48 (the v2.12.41 → v2.12.47 builds shipped on :preview only as amd64-only smoke builds while a buildkit multi-arch issue was being diagnosed).