🏆 Performance milestone

CaddyUI v2.12.48 — Perfect Lighthouse: 99 / 100 / 100 / 100

After an eleven-version perf + a11y wave (v2.12.38v2.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.

v2.12.48 ☕ ~9 min read
99
Performance
100
Accessibility
100
Best Practices
100
SEO
FCP 0.5 s · LCP 0.9 s · TBT 0 ms · CLS 0.001 · Speed Index 0.6 s

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

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 + htmx, service-worker rewrite (auto-purges stale caches per release)~50~80
v2.12.40Preload Inter font, preconnect cdn.tailwindcss.com~5591
v2.12.41Externalize 28 KB inline JS to /static/app.js (cached across navigations)~55~95
v2.12.42Preload app.js, defer non-critical /api/version-check + /api/ai/status~55~96
v2.12.43auth.css for /login (drops Tailwind CDN there) + Cloudflare Turnstile (replaces reCAPTCHA)76~99
v2.12.44Section anchor pills on the proxy host form, ordered by frequency7699
v2.12.45Physical reorder of form sections to match pill priority7699
v2.12.46aria-current, aria-labels on icon buttons, skip-to-main-content link7699
v2.12.47Label ↔ input associations on every unauth page (for=/id=)7699
v2.12.48<meta name="description"> for SEO 1007699

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 (curlweb/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:

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:

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:

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.

One-time service-worker cleanup. If you're upgrading from v2.12.38 or earlier, the old caddyui-v1 SW may serve stale assets even after the pull. Once: F12 → ApplicationService WorkersUnregister 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).