Summary
VITE_BASE_PATH is documented as the supported way to serve the admin panel under a URL subpath, but a subpath deployment does not currently work end-to-end. This PR fixes the three server-side issues that block it and documents the deployment shape it enables: admin panel on /admin of the same hostname as LibreChat — e.g. https://chat.example.com/ for LibreChat and https://chat.example.com/admin/ for the panel, with one hostname, one certificate, and one DNS record.
Most of the plumbing is already in place. The build, the client router, static asset serving, and metrics path normalisation all respect VITE_BASE_PATH. What breaks is the server routing layer and a few related concerns (health checks, cookie scope, OAuth callback URL).
Problem
What already works
vite.config.ts → base: process.env.VITE_BASE_PATH || '/'
src/router.tsx → basepath: import.meta.env.VITE_BASE_PATH || '/'
server.ts → buildStaticRoutes() serves assets at `${BASE_PATH}/${path}`
src/server/metrics.ts → normalizeMetricsPath() strips BASE_PATH from metric labels
Dockerfile exposes ARG VITE_BASE_PATH
What does not
| # |
Issue |
Location |
| 1 |
Redirect loop on the base path |
server.ts |
| 2 |
/health and /metrics not mounted under BASE_PATH |
server.ts |
| 3 |
Session cookie scoped to / instead of BASE_PATH |
src/server/session.ts |
| 4 |
OpenID callback URL may omit BASE_PATH |
src/server/utils/oauth.ts |
| 5 |
Build↔runtime coupling undocumented |
README.md, .env.example |
Root cause
1. Redirect loop
routes: {
...(await buildStaticRoutes()),
'/metrics': (req) => metricsResponse(req),
'/health': () => new Response('ok'),
...(BASE_PATH ? { [`${BASE_PATH}`]: () => Response.redirect(`${BASE_PATH}/`, 302) } : {}),
'/*': async (req) => { /* SSR handler */ },
}
Two problems in the [BASE_PATH] route:
- The route registered for
/admin also matches /admin/ in the Bun.serve route matcher, so the redirect target re-enters the same handler → ERR_TOO_MANY_REDIRECTS.
Response.redirect() receives a relative URL; the Fetch spec requires an absolute URL.
The redirect is also unnecessary: TanStack Router is configured with basepath, so /admin (no trailing slash) already resolves through the '/*' SSR handler.
2. Health and metrics unreachable behind a subpath proxy
/health and /metrics are mounted at the origin root only, while static assets are served under BASE_PATH. When a reverse proxy forwards only ${BASE_PATH}/* to the panel:
GET /admin/health falls through to '/*' and returns SSR HTML (status 200, body is not ok)
GET /health is never routed to the panel by the proxy
- Prometheus scraping through the ingress stops working
3. Session cookie leaks to sibling apps on a shared hostname
On a shared hostname, the panel and LibreChat share an origin and cookie jar. The admin session cookie is currently emitted with Path=/, so it is sent on every LibreChat request (/api/*, uploads, SSE) even though only the panel can use it.
Proposed changes
server.ts
- Extract base-path normalisation into a shared helper (leading slash enforced, trailing slash stripped,
'/' → '').
- Remove the self-matching
[BASE_PATH] redirect route. Move trailing-slash canonicalisation into the '/*' handler using an explicit 308 + Location header (no relative Response.redirect()).
- Mount
/health and /metrics under BASE_PATH in addition to the existing root mounts (backwards compatible with direct container probes).
- Warn at startup when runtime
VITE_BASE_PATH does not match the value baked into the client bundle at build time.
src/server/session.ts
- Set the session cookie
Path to BASE_PATH || '/'.
src/server/utils/oauth.ts
- Build the OpenID redirect URI with
BASE_PATH prepended so the callback lands on ${BASE_PATH}/auth/openid/callback.
Docs
README.md: "Serving under a subpath" section — build-time/runtime coupling, nginx location example, Kubernetes Gateway API HTTPRoute example, ADMIN_PANEL_URL on the LibreChat side, probe/scrape paths.
.env.example: expand the VITE_BASE_PATH comment.
docker-compose.yml: commented VITE_BASE_PATH build arg + env pair showing both places that must agree.
No behaviour change at the default
With VITE_BASE_PATH unset, BASE_PATH is '': subpath-prefixed routes are not registered, canonicalisation is skipped, cookie path stays /. Root deployments are unaffected.
Change Type
Test plan
Before (current main)
docker build -t admin-panel:subpath --build-arg VITE_BASE_PATH=/admin .
docker run --rm -p 3000:3000 \
-e SESSION_SECRET="$(openssl rand -hex 32)" \
-e VITE_BASE_PATH=/admin \
-e VITE_API_BASE_URL=http://host.docker.internal:3080 \
-e SESSION_COOKIE_SECURE=false \
admin-panel:subpath
curl -sI http://localhost:3000/admin # 302 loop
curl -s http://localhost:3000/admin/health # SSR HTML, not "ok"
curl -sI http://localhost:3000/admin/ | grep -i set-cookie # Path=/
After
curl -sI http://localhost:3000/admin # 308 → /admin/ (single hop)
curl -sI http://localhost:3000/admin/ # 200 text/html
curl -s http://localhost:3000/admin/health # ok
curl -s http://localhost:3000/health # ok (unchanged)
curl -s -H 'Authorization: Bearer $ADMIN_PANEL_METRICS_SECRET' \
http://localhost:3000/admin/metrics | head
curl -sI http://localhost:3000/admin/ | grep -i set-cookie # Path=/admin
Shared-hostname scenario
LibreChat on /, panel on /admin, reverse proxy in front:
https://chat.example.com/ → LibreChat loads.
https://chat.example.com/admin → single redirect to /admin/, panel loads, assets from /admin/assets/*.
- Email/password login → session established, no redirect loop.
- OpenID SSO with
ADMIN_PANEL_URL=https://chat.example.com/admin → callback at /admin/auth/openid/callback, PKCE succeeds.
- Direct navigation to
/admin/access (SSR) → renders.
- Admin session cookie is not sent on
/api/* requests.
- Rebuild without
VITE_BASE_PATH → root deployment unchanged.
Automated
src/server/utils/url.test.ts: base-path normalisation (undefined, '', /, admin, /admin, /admin/)
- Route tests:
/admin/health and /admin/metrics when BASE_PATH=/admin
- Session tests: cookie
Path when BASE_PATH is set
Summary
VITE_BASE_PATHis documented as the supported way to serve the admin panel under a URL subpath, but a subpath deployment does not currently work end-to-end. This PR fixes the three server-side issues that block it and documents the deployment shape it enables: admin panel on/adminof the same hostname as LibreChat — e.g.https://chat.example.com/for LibreChat andhttps://chat.example.com/admin/for the panel, with one hostname, one certificate, and one DNS record.Most of the plumbing is already in place. The build, the client router, static asset serving, and metrics path normalisation all respect
VITE_BASE_PATH. What breaks is the server routing layer and a few related concerns (health checks, cookie scope, OAuth callback URL).Problem
What already works
vite.config.ts→base: process.env.VITE_BASE_PATH || '/'src/router.tsx→basepath: import.meta.env.VITE_BASE_PATH || '/'server.ts→buildStaticRoutes()serves assets at`${BASE_PATH}/${path}`src/server/metrics.ts→normalizeMetricsPath()stripsBASE_PATHfrom metric labelsDockerfileexposesARG VITE_BASE_PATHWhat does not
server.ts/healthand/metricsnot mounted underBASE_PATHserver.ts/instead ofBASE_PATHsrc/server/session.tsBASE_PATHsrc/server/utils/oauth.tsREADME.md,.env.exampleRoot cause
1. Redirect loop
Two problems in the
[BASE_PATH]route:/adminalso matches/admin/in theBun.serveroute matcher, so the redirect target re-enters the same handler →ERR_TOO_MANY_REDIRECTS.Response.redirect()receives a relative URL; the Fetch spec requires an absolute URL.The redirect is also unnecessary: TanStack Router is configured with
basepath, so/admin(no trailing slash) already resolves through the'/*'SSR handler.2. Health and metrics unreachable behind a subpath proxy
/healthand/metricsare mounted at the origin root only, while static assets are served underBASE_PATH. When a reverse proxy forwards only${BASE_PATH}/*to the panel:GET /admin/healthfalls through to'/*'and returns SSR HTML (status 200, body is notok)GET /healthis never routed to the panel by the proxy3. Session cookie leaks to sibling apps on a shared hostname
On a shared hostname, the panel and LibreChat share an origin and cookie jar. The admin session cookie is currently emitted with
Path=/, so it is sent on every LibreChat request (/api/*, uploads, SSE) even though only the panel can use it.Proposed changes
server.ts'/'→'').[BASE_PATH]redirect route. Move trailing-slash canonicalisation into the'/*'handler using an explicit308+Locationheader (no relativeResponse.redirect())./healthand/metricsunderBASE_PATHin addition to the existing root mounts (backwards compatible with direct container probes).VITE_BASE_PATHdoes not match the value baked into the client bundle at build time.src/server/session.tsPathtoBASE_PATH || '/'.src/server/utils/oauth.tsBASE_PATHprepended so the callback lands on${BASE_PATH}/auth/openid/callback.Docs
README.md: "Serving under a subpath" section — build-time/runtime coupling, nginxlocationexample, Kubernetes Gateway APIHTTPRouteexample,ADMIN_PANEL_URLon the LibreChat side, probe/scrape paths..env.example: expand theVITE_BASE_PATHcomment.docker-compose.yml: commentedVITE_BASE_PATHbuild arg + env pair showing both places that must agree.No behaviour change at the default
With
VITE_BASE_PATHunset,BASE_PATHis'': subpath-prefixed routes are not registered, canonicalisation is skipped, cookie path stays/. Root deployments are unaffected.Change Type
Test plan
Before (current
main)After
Shared-hostname scenario
LibreChat on
/, panel on/admin, reverse proxy in front:https://chat.example.com/→ LibreChat loads.https://chat.example.com/admin→ single redirect to/admin/, panel loads, assets from/admin/assets/*.ADMIN_PANEL_URL=https://chat.example.com/admin→ callback at/admin/auth/openid/callback, PKCE succeeds./admin/access(SSR) → renders./api/*requests.VITE_BASE_PATH→ root deployment unchanged.Automated
src/server/utils/url.test.ts: base-path normalisation (undefined,'',/,admin,/admin,/admin/)/admin/healthand/admin/metricswhenBASE_PATH=/adminPathwhenBASE_PATHis set