feat: support deployment behind a reverse proxy under a sub-path#9306
Conversation
The web frontend derived all URLs from window.location.origin and hardcoded paths (/api, /ws/socket.io, /locales, /openapi.json), so running Invoke behind a reverse proxy under a sub-path (e.g. https://host/invoke/) broke because the prefix was lost. Frontend: add a shared helper (common/util/baseUrl.ts) that auto-detects the deployment prefix from the entry bundle URL (import.meta.url), relying on Vite's existing `base: './'`. getBaseUrl(), the socket.io path, i18n loadPath, the openapi fetch and the React Router basename now all use it. At the domain root the prefix is empty, so behavior is byte-identical to before. Covered by unit tests. Backend: add an opt-in `base_url` setting (plus `forwarded_allow_ips`). When set, the app is wrapped in SubPathASGIMiddleware which strips the prefix from the request path and advertises root_path, handling both proxy styles (preserve and strip). uvicorn's root_path is intentionally not used, as it prepends the prefix and breaks the preserve case. Default (unset) leaves existing installs unchanged. Tested end-to-end with Docker + Caddy for preserve, strip, and zero-config strip.
lstein
left a comment
There was a problem hiding this comment.
Thanks — this is a well-constructed PR. The import.meta.url auto-detection on the frontend is elegant and unit-tested, the base_url validator normalizes correctly (I exercised '/invoke/', '//invoke//', ' /x ', '', '/' — all behave as documented), defaults are a strict no-op, and the dual proxy-style handling works for the main flows. I ran the new frontend tests (6/6 pass) and exercised the middleware against Starlette 0.48/FastAPI with a TestClient to verify behavior empirically.
Requesting changes for one functional gap plus two confirmed backend edge cases; details in the inline comments where the code is in the diff.
1. Login/setup flows break under a sub-path (blocking)
The PR sets the React Router basename, but two auth components navigate with hard-coded absolute paths that bypass it (these files aren't in the diff, so noting here rather than inline):
invokeai/frontend/web/src/features/auth/components/LoginPage.tsx:65—window.location.href = '/app'(the deliberate full reload for multiuser state isolation)invokeai/frontend/web/src/features/auth/components/AdministratorSetup.tsx:63—window.location.href = '/login'
Under https://host/invoke/, completing login or admin setup navigates to https://host/app / https://host/login — outside the proxied prefix, typically a proxy 404. Since these are the entry points when auth is enabled, sub-path support is broken for multiuser installs. Fix is small and keeps the full-reload semantics:
window.location.href = `${getBasePath()}/app`; // and '/login' in AdministratorSetup2. Server-generated redirects lose the prefix (confirmed empirically)
Because the middleware strips base_path from scope["path"] while Starlette builds redirect URLs from scope["path"] alone, every server-generated redirect drops the prefix. TestClient results with base_url=/invoke:
GET /invoke/api/v1/images -> 200 (routing ✓)
GET /invoke/api/v1/boards -> 307 Location: http://host/api/v1/boards/ (prefix lost)
GET /invoke/?__theme=dark -> 307 Location: / (prefix lost)
The SPA always sends canonical URLs so it won't hit the trailing-slash 307s, but direct API consumers will — and the ?__theme=dark case is exactly what RedirectRootWithQueryStringMiddleware exists for, and it now lands the user at the domain root. See the inline comment on SubPathASGIMiddleware for a suggested fix (prepend instead of strip).
3. base_url colliding with a real route prefix silently bricks the app (confirmed)
With base_url: /api, both delivery styles 404: /api/v1/images gets eaten by the strip logic, and /api/api/v1/images gets double-stripped by Starlette's get_route_path (which removes root_path from path a second time). Same for /ws, /static, /docs, /redoc, /locales, /assets, /openapi.json. The failure mode is a completely dead server with no hint why — see inline comment on the validator.
Minor
- No backend tests for the middleware or validator, and both are trivially unit-testable — given the subtle proxy-style matrix above, a small test would protect this from regressing. Happy to share the TestClient harness I used for the results above.
lstein
left a comment
There was a problem hiding this comment.
See inline comments above for issues picked up during code review.
Address blockers from PR review for reverse-proxy sub-path support:
- Auth flows: LoginPage/AdministratorSetup navigated to hardcoded
'/app' and '/login', bypassing the router basename and breaking
multiuser installs under a sub-path. Prefix with getBasePath().
- SubPathASGIMiddleware: invert the rewrite — prepend the prefix for
strip-style requests instead of stripping it for preserve-style ones,
so scope["path"] holds the full public path (ASGI-compliant) and
Starlette keeps the prefix on server-generated redirects (trailing-
slash 307s, ?__theme=dark). RedirectRootWithQueryStringMiddleware is
now root_path-aware.
- Config: reject base_url whose first segment collides with a real
route prefix (api, ws, static, docs, redoc, openapi.json, locales,
assets) instead of silently bricking the server.
- baseUrl: fall back to the app origin when the bundle is served from a
foreign (CDN) origin; use lastIndexOf('/assets/') for robustness.
- run_app: move typing import to top, drop redundant proxy_headers=True.
- Add backend tests for the middleware (both proxy styles) and the
base_url validator; extend baseUrl frontend tests.
engine.io is not root_path-aware and matches the raw scope["path"] against its configured socketio_path. Once base_url sets root_path, Starlette hands the mounted app the full public path (/invoke/ws/socket.io), so every handshake 404'd and the UI lost all real-time updates behind the proxy. Fold base_url into socketio_path.
Resolve conflict in useSocketIO.ts between the sub-path socket URL/path derivation (this PR) and the per-user auth-hydration gating from invoke-ai#9358: keep both import lines; the hook body combines cleanly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lstein
left a comment
There was a problem hiding this comment.
All review comments addressed. LGTM
|
Pushed a merge of Also: nice catch on the engine.io handshake 404 — the prepend approach I suggested did introduce that, and folding |
Summary
The web frontend derived all URLs from
window.location.originand used hardcoded paths (/api,/ws/socket.io,/locales,/openapi.json), so running Invoke behind a reverse proxy under a sub-path (e.g.https://host/invoke/) broke because the prefix was lost.Frontend: adds a shared helper (
common/util/baseUrl.ts) that auto-detects the deployment prefix from the entry bundle URL (import.meta.url), relying on Vite's existingbase: './'.getBaseUrl(), the socket.io path, the i18nloadPath, the openapi fetch and the React Routerbasenamenow all use it. At the domain root the prefix is empty, so behavior is byte-identical to before. Covered by unit tests.Backend: adds an opt-in
base_urlsetting (plusforwarded_allow_ips). When set, the app is wrapped inSubPathASGIMiddleware, which strips the prefix from the request path and advertisesroot_path, handling both proxy styles (preserve and strip). uvicorn'sroot_pathis intentionally not used, because it prepends the prefix to the request path and breaks the preserve case (the prefix would appear twice). Default (unset) leaves existing installs completely unchanged.Two deployment scenarios are supported:
base_urlis optional but recommended so/docs+ openapi URLs are correct.base_urlso the middleware can strip the prefix for routing.QA Instructions
Tested end-to-end with Docker + a Caddy reverse proxy in three configurations; every endpoint was checked for the correct content type and body (not just status code):
/docsbase_url=/invoke/invoke/openapi.json)base_url=/invokebase_urlunset)The frontend prefix-detection logic is also covered by unit tests (
baseUrl.test.ts).To reproduce locally (production build required — the dev server has no
/assets/segment, so auto-detection is a no-op there):Strip proxy (Caddy):
Leave
base_urlunset (or setbase_url: /invoketo also fix/docs).Preserve proxy (nginx):
Set
base_url: /invokeandforwarded_allow_ips: <proxy-ip>ininvokeai.yaml.Then browse
http://localhost:8080/invoke/and confirm: UI loads, generations stream progress (websocket), language switching loads locales, and a reload restores client state. Also verify a regression check at the domain root (http://localhost:9090/) still works withbase_urlunset.Merge Plan
Normal merge. No DB schema or redux slice changes. The config schema gains two opt-in fields (
base_url,forwarded_allow_ips); both default to a no-op, so no migration is needed.Checklist
What's Newcopy (if doing a release after this PR)