ADR-023: LibreChat Theming via Overlay on Upstream Image
Status: Accepted Date: 2026-08-31 Decision Makers: Human + AI Prompted By: HQ was re-platformed from a custom Next.js app to a vanilla LibreChat install (upstream pre-built image, no build step — ADR-021’s Auth0 approach was dropped with it). The vanilla install renders with LibreChat’s own branding (green accents, feather icons, logo, title, favicons) and light/dark themes, which is inconsistent with the kevinryan.io brand and unwanted on an internal tool.
Context
Section titled “Context”hq.kevinryan.io deploys the upstream multi-container LibreChat image
(registry.librechat.ai/danny-avila/librechat) plus an internal MongoDB,
managed by Flux from k8s/hq-kevinryan-io/. The repo deliberately has no
Dockerfile and no committed app source for this site: the shared CI deploy
workflow auto-skips it, and upstream releases are frequent enough that
maintaining even a thin fork means owning a build pipeline, image scanning,
and rebase churn for a solo operator.
The branding requirements are:
- Tokyo Night Moon everywhere — the same palette as kevinryan.io, forced dark-only (no light theme, no theme toggle).
- No LibreChat branding — feather icons, login logo, footer version string, and default title removed; “HQ - Kevin Ryan & Associates” title; kevinryan.io favicons; square-cornered auth inputs matching the brand.
LibreChat’s built-in customization surface (CSS-variable theming,
librechat.yaml config) covers only part of this: the app ships compiled
Tailwind utilities with literal hex colors that ignore the variable system,
the service worker caches CSS aggressively, and title/favicons/icon
defaults live in the served index.html and asset files, not in config.
Decision Drivers
Section titled “Decision Drivers”- No fork, no build pipeline — hq must stay a manifest-only deployment; the value of the vanilla install is zero build maintenance.
- Everything in Git — all customization must be declarative, reviewable, and deployable by Flux, not baked into an image or applied by hand.
- Upstream updates remain reachable — security fixes should be one deliberate step away, not a rebase project.
- Solo operator — silent visual drift is the worst failure mode; loud, blocking failures are preferred.
Options Considered
Section titled “Options Considered”Option A: Fork with a custom build
Section titled “Option A: Fork with a custom build”Clone LibreChat, modify theme/i18n/assets, build and push our own image to ACR alongside the other sites.
Trade-offs: Full control and clean implementation (real i18n overrides,
no !important fights). But it owns a build pipeline for a large, fast-moving
upstream: rebases on every release, image CVE scanning, and CI that no longer
auto-skips the site. High maintenance for a single-user internal tool.
Option B: Overlay on the unmodified upstream image
Section titled “Option B: Overlay on the unmodified upstream image”Keep pulling the upstream image (digest-pinned), and layer all customization at deploy time:
- A
patch-indexinitContainer copies the image’sindex.htmlandassets/into anemptyDirandseds in the stylesheet link, title, loading-screen colors, and dark-mode forcing — plus the en-locale login heading (com_auth_welcome_back) in the compiled JS bundle — followed by post-patch guards thatexit 1if any expected post-condition is missing, so an upstream change fails the rollout loudly instead of deploying an unthemed site. - A
librechat-customConfigMap (generated byscripts/sync-hq-theme.shfrom sources insites/hq-kevinryan-io/) mountscustom-theme.css, a transparentlogo.svg, and favicons over/app/client/dist/. - The
?v=cache-buster is derived from the CSS content hash (sha256-8), so the service worker cache busts exactly when the CSS changes. - CI (
validate.yml) runsscripts/sync-hq-theme.sh --checkto block drift between sources and the generated ConfigMap.
Trade-offs: Zero build infrastructure; all customization is declarative
and in Git; upstream remains an unmodified artifact. But the overlay is
brittle to upstream UI changes — seds and CSS selectors target specific
markup — and the CSS must override compiled Tailwind literals with
!important.
Option C: Proxy-level rewriting
Section titled “Option C: Proxy-level rewriting”Rewrite responses at the edge (nginx sub_filter or a Traefik middleware)
to inject the stylesheet and rewrite markup.
Trade-offs: No pod-level patching. But streaming/chunked responses and
gzip complicate sub_filter reliably, the service worker still caches the
unpatched shell, and it couples branding to the ingress layer rather than
the workload. Favicons and other asset files still need volume mounts, so
it only removes the initContainer, not the ConfigMap layer.
Option D: LibreChat built-in theming only
Section titled “Option D: LibreChat built-in theming only”Use LibreChat’s CSS-variable theming and librechat.yaml customization
limits, accept remaining branding.
Trade-offs: Cheapest possible. But it cannot recolor the compiled Tailwind utility classes (literal hex), cannot force dark-only across the service-worker-cached shell, and cannot replace title, favicons, or the login logo. Meets the “no maintenance” driver but fails the branding requirements outright.
Decision
Section titled “Decision”Option B: overlay on the unmodified upstream image.
The overlay keeps every property that made the vanilla install attractive —
no build step, no fork, upstream image integrity, manifest-only GitOps —
while meeting the full branding requirement. The brittleness that usually
makes overlays a bad idea is converted into a managed failure mode: the
image is digest-pinned (never :latest), the sed patches are followed
by fail-loud guards, and image upgrades require a throwaway-pod guard
test against the new digest before any manifest change (procedure in
.pi/skills/librechat-hq-theme-patch/SKILL.md). Drift between the theme
sources and the deployed ConfigMap is blocked in CI.
A fork (Option A) becomes the right answer if the overlay maintenance cost grows — concretely, if an upstream upgrade routinely requires rewriting the sed/guard layer rather than just re-running the guard test.
Consequences
Section titled “Consequences”Positive
Section titled “Positive”- hq stays a zero-build site: no Dockerfile, no CI build job, Flux-only deploys
- All branding is declarative and versioned in Git (sources + generated ConfigMap + manifests)
- Upstream security releases are one deliberate digest bump away
- Upstream UI drift fails loudly at rollout (guards) or in CI (drift check) — never silently
- Cache busting is derived from content, ending the manual
?v=Nbump failure mode
Negative
Section titled “Negative”- The theme CSS fights the compiled Tailwind layer with
!importantoverrides; upstream UI changes can require CSS maintenance - Image upgrades are manual and gated by a guard test — a deliberate trade of cadence for control
- The login heading is patched in the compiled en locale only; other locales keep upstream strings (acceptable for a solo English user)
- Upstream changes
index.htmlshape: seds no-op and guards fail the rollout — loud, but the site stays on the old replica set until the guards are updated. Mitigation: the pre-bump throwaway-pod guard test catches this before any manifest change. - Digest pin goes stale / forgotten upgrades: the app stops receiving upstream security fixes. Mitigation: pinning is a documented, deliberate procedure in the skill; treat LibreChat release notes as a periodic checklist item.
- Accessibility of the login heading (resolved): the original CSS
hack (
font-size: 0+::after) left “Welcome back” in the accessibility tree. Replaced by patching the compiled en-locale string (com_auth_welcome_back→ “HQ”) in the JS bundle — real DOM text, announced correctly by screen readers. The service-worker precache is bypassed by rewriting asset URLs inindex.htmlwith a content-hash?v=, so browsers with a warm SW cache receive the patched bundle. - Selector overreach (resolved):
button[aria-label*="theme" i]hid any button whose label merely contained “theme”. Now exact-match only ("Toggle theme"/"Ocultar tema", both verified as literals in the upstream bundle); verified per image bump as part of the guard test.
Agent Decisions
Section titled “Agent Decisions”| Decision | Rationale | Acceptable |
|---|---|---|
Pin to the digest actually running (sha256:c5db3331…) rather than latest stable | Pin what was verified working; upgrades are separate deliberate changes | Yes |
| Five specific post-patch grep guards (stylesheet, title, dark-mode, theme-color, repaint) | Post-condition checks catch every sed no-op without over-constraining the upstream shape | Yes |
?v= = first 8 hex of sha256(custom-theme.css) | Deterministic, changes iff the CSS changes, no manual bump to forget | Yes |
Generate the ConfigMap via kubectl create configmap --dry-run=client | Canonical output format, offline, matches cluster-side rendering | Yes |
Basename-only .yamllint ignore patterns | lint-staged passes absolute paths; gitignore-anchored patterns never matched them — root cause of the historical --no-verify commits | Yes |
Sed the en-locale com_auth_welcome_back string in the compiled JS bundle | LibreChat compiles locales into the bundle (no runtime i18n files exist in the image) — it is the only overlay-compatible way to change the string, and real DOM text fixes the a11y problem | Yes |
Rewrite asset URLs in index.html with ?v=<sha256-8 of patched bundle> | The SW precaches app JS by query-less URL; the query makes browsers with a warm SW cache fetch the patched bundle from the network | Yes |
Delete the precompressed .br/.gz siblings of the patched bundle | The static server prefers precompressed siblings when the client sends Accept-Encoding — the plain-.js sed alone never reached browsers (Cloudflare served the stale .br) | Yes |
References
Section titled “References”- ADR-021: Auth0 for HQ Authentication (superseded — Auth0 dropped in the re-platform to LibreChat native auth)
- ADR-009: CI/CD with GitHub Actions and Flux CD
- ADR-018: Secret Management with Azure Key Vault and ESO
.pi/skills/librechat-hq-theme-patch/SKILL.md— executable procedurescripts/sync-hq-theme.sh,k8s/hq-kevinryan-io/deployment.yaml- AGENTS.md — “hq.kevinryan.io” site entry and Agent Skills section