Skip to content

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.

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.

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

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-index initContainer copies the image’s index.html and assets/ into an emptyDir and seds 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 that exit 1 if any expected post-condition is missing, so an upstream change fails the rollout loudly instead of deploying an unthemed site.
  • A librechat-custom ConfigMap (generated by scripts/sync-hq-theme.sh from sources in sites/hq-kevinryan-io/) mounts custom-theme.css, a transparent logo.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) runs scripts/sync-hq-theme.sh --check to 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.

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.

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.

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.

  • 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=N bump failure mode
  • The theme CSS fights the compiled Tailwind layer with !important overrides; 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.html shape: 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 in index.html with 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.
DecisionRationaleAcceptable
Pin to the digest actually running (sha256:c5db3331…) rather than latest stablePin what was verified working; upgrades are separate deliberate changesYes
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 shapeYes
?v= = first 8 hex of sha256(custom-theme.css)Deterministic, changes iff the CSS changes, no manual bump to forgetYes
Generate the ConfigMap via kubectl create configmap --dry-run=clientCanonical output format, offline, matches cluster-side renderingYes
Basename-only .yamllint ignore patternslint-staged passes absolute paths; gitignore-anchored patterns never matched them — root cause of the historical --no-verify commitsYes
Sed the en-locale com_auth_welcome_back string in the compiled JS bundleLibreChat 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 problemYes
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 networkYes
Delete the precompressed .br/.gz siblings of the patched bundleThe 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