docs/MultiSiteDeployment.md
This document explains how to run multiple isolated LightRAG instances behind one host using a reverse proxy (nginx, Traefik, Kubernetes Ingress, …), with one shared WebUI build reused by every instance.
Looking for the basic single-instance Docker setup? See DockerDeployment.md. For frontend build mechanics in general, see FrontendBuildGuide.md.
LIGHTRAG_API_PREFIX per-instance, on the backend only. The WebUI is always mounted at /webui (not configurable).# One image, two containers, two prefixes — no rebuild.
docker run -e LIGHTRAG_API_PREFIX=/site01 -p 9621:9621 lightrag:latest
docker run -e LIGHTRAG_API_PREFIX=/site02 -p 9622:9621 lightrag:latest
Earlier versions of LightRAG baked the site prefix into the JavaScript bundle at build time (via VITE_API_PREFIX / VITE_WEBUI_PREFIX). Every site that used a different prefix needed its own WebUI build, and reusing a single Docker image across sites required a rebuild step at deploy time. Since the runtime-config-injection refactor:
index.html are emitted as relative paths (./assets/index-abc.js). The browser resolves them against the current document URL, so they work under any mount point.window.__LIGHTRAG_CONFIG__, which the FastAPI server injects into index.html on each response based on its own LIGHTRAG_API_PREFIX.The result: a single lightrag/api/webui/ directory (or Docker image) is reusable across any number of sites with no per-site build artifact.
Each request for index.html goes through SmartStaticFiles in lightrag/api/lightrag_server.py, which:
index.html produced by bun run build.<!-- __LIGHTRAG_RUNTIME_CONFIG__ -->.<script>window.__LIGHTRAG_CONFIG__ = {"apiPrefix":"…","webuiPrefix":"…","webuiTitle":"…"}</script>,
computed from the configured LIGHTRAG_API_PREFIX (the in-app /webui mount is hardcoded server-side).Sequence — browser request to a site-prefixed instance:
Browser nginx uvicorn SmartStaticFiles
│ │ │ │
│ GET /site01/webui/ │ │
│─────────────────►│ │ │
│ │ GET /webui/ (strips /site01) │
│ │──────────────────────►│ │
│ │ │ get_response("") │
│ │ │───────────────────►│
│ │ │ │ inject
│ │ │ │ window.__LIGHTRAG_CONFIG__
│ │ │ │ = { apiPrefix: "/site01",
│ │ │ │ webuiPrefix: "/site01/webui/" }
│ │ │◄───────────────────│
│ │◄──────────────────────│ │
│◄─────────────────│ │ │
│ index.html with injected runtime config
The SPA reads the injected config via src/lib/runtimeConfig.ts and uses
it for axios.baseURL, fetch() template strings, the API-docs iframe,
and in-app links.
| Variable | Default | Meaning |
|---|---|---|
LIGHTRAG_API_PREFIX | "" | Reverse-proxy mount prefix. The backend accepts both strip and verbatim forwarding — pick whichever fits your proxy stack. Passed to FastAPI as root_path. |
LIGHTRAG_DEFAULT_UI | webui | Which UI entry the root path / redirects to: webui (admin) or workspace (query-only entry). Controls ONLY the / redirect; both entries stay mounted regardless. Any other value fails startup. |
There are TWO WebUI entries, both mounted server-side from one shared build: the admin UI at /webui and the query-user entry at /workspace (each serving its own entry HTML from the same static directory; requesting the other entry's HTML file returns 404). window.__LIGHTRAG_CONFIG__ keeps the published shape {apiPrefix, webuiPrefix, webuiTitle}; webuiPrefix is computed as LIGHTRAG_API_PREFIX + "/webui/" and injected identically for both entries — you do not set it yourself, and there is no entry-mode field (the entry identity IS the loaded HTML product). Behind a prefix, browsers see /site01/webui/ and /site01/workspace/. webuiTitle carries WEBUI_TITLE (null when unset) and is applied to the browser tab title by the same injected snippet, so the tab shows the deployment's own name from the first paint instead of the bundled default.
Sites on one host share the browser's client-side settings. The WebUI keeps its query parameters and chat histories in
localStorage, which the browser scopes by ORIGIN — and every prefix on one host is the same origin. The keys are not namespaced by prefix yet, so query parameters saved in/site01/webuiare the ones/site02/workspacesends, in that browser. This is client-side state only: each instance's server-side data isolation (workspace, storage backends, authentication) is untouched, and another visitor's browser is unaffected. Give the sites separate hostnames if their UI state must not mix.
There are no longer any frontend VITE_API_PREFIX / VITE_WEBUI_PREFIX variables. Setting them has no effect (they are ignored by the build).
After setting LIGHTRAG_API_PREFIX=/site01, the backend resolves all routes correctly under either forwarding style:
/webui/ and /documents/foo. The nginx example below uses this style./site01/webui/ and /site01/documents/foo. The Vite dev flow (Scenario 2) and any non-rewriting proxy use this style.A small ASGI middleware in create_app prepends root_path to scope["path"] whenever the path does not already include it, so plain Routes and Mount sub-apps (the WebUI's StaticFiles) both resolve identically in either mode. You do not need to standardize on one — both coexist on the same backend without configuration toggles.
WHITELIST_PATHS is written without the prefixEvery path the server compares against configuration — WHITELIST_PATHS above all — is matched after the mount prefix is removed, identically in both forwarding modes. Entries are the internal route paths, exactly as the routes are declared:
LIGHTRAG_API_PREFIX=/site01
WHITELIST_PATHS=/health,/api/* # correct: exempts /site01/health and /site01/api/chat
# WHITELIST_PATHS=/site01/health # WRONG: matches nothing, /site01/health then needs auth
So the shipped default needs no change when you add a prefix: /health keeps answering unauthenticated liveness probes, and /api/* keeps exempting the Ollama-compatible routes — and only those. A /* entry matches on path-segment boundaries, so /api/* never spills onto a sibling route that merely starts with the same characters.
site01.env:
HOST=0.0.0.0
PORT=9621
LIGHTRAG_API_PREFIX=/site01
WORKING_DIR=/data/site01/storage
INPUT_DIR=/data/site01/inputs
LIGHTRAG_API_KEY=site01-secret
# … LLM / embedding config …
site02.env:
HOST=0.0.0.0
PORT=9621
LIGHTRAG_API_PREFIX=/site02
WORKING_DIR=/data/site02/storage
INPUT_DIR=/data/site02/inputs
LIGHTRAG_API_KEY=site02-secret
# … LLM / embedding config …
services:
site01:
image: ghcr.io/hkuds/lightrag:latest
env_file: site01.env
volumes:
- ./data/site01:/data/site01
ports:
- "127.0.0.1:9621:9621"
site02:
image: ghcr.io/hkuds/lightrag:latest
env_file: site02.env
volumes:
- ./data/site02:/data/site02
ports:
- "127.0.0.1:9622:9621"
server {
listen 443 ssl http2;
server_name host.example.com;
# site01: strips /site01/ before forwarding
location /site01/ {
proxy_pass http://127.0.0.1:9621/;
proxy_set_header X-Forwarded-Prefix /site01;
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
# site02: strips /site02/ before forwarding
location /site02/ {
proxy_pass http://127.0.0.1:9622/;
proxy_set_header X-Forwarded-Prefix /site02;
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}
Browsing https://host.example.com/site01/webui/ shows site01's WebUI; https://host.example.com/site02/webui/ shows site02's. The same Docker image serves both — no per-site build artifact, no rebuild on prefix changes.
| Layer | site01 GET /webui/ |
|---|---|
| Browser address bar | https://host.example.com/site01/webui/ |
| nginx receives | /site01/webui/ |
| nginx forwards | /webui/ |
FastAPI root_path | /site01 |
app.mount resolves | /webui/ |
Injected apiPrefix | /site01 |
Injected webuiPrefix | /site01/webui/ |
| Asset URLs in HTML | ./assets/index-abc.js (resolves to https://host.example.com/site01/webui/assets/index-abc.js) |
The Dockerfile builds the WebUI once, with no prefix:
FROM oven/bun:1 AS webui-build
WORKDIR /src/lightrag_webui
COPY lightrag_webui/package.json lightrag_webui/bun.lock ./
RUN bun install --frozen-lockfile
COPY lightrag_webui/ ./
COPY lightrag/api/webui/.gitkeep /src/lightrag/api/webui/.gitkeep
RUN bun run build
FROM python:3.11-slim
COPY --from=webui-build /src/lightrag/api/webui /app/lightrag/api/webui
# … rest of the image …
Run any number of containers from the same image, each with its own prefix:
# Plain single-instance, no prefix.
docker run --rm -p 9621:9621 lightrag:latest
# Same image, different prefixes — runtime decides.
docker run --rm -e LIGHTRAG_API_PREFIX=/site01 -p 9621:9621 lightrag:latest
docker run --rm -e LIGHTRAG_API_PREFIX=/site02 -p 9622:9621 lightrag:latest
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: lightrag-multisite
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /$2
spec:
rules:
- host: host.example.com
http:
paths:
- path: /site01(/|$)(.*)
pathType: ImplementationSpecific
backend:
service:
name: lightrag-site01
port: { number: 9621 }
- path: /site02(/|$)(.*)
pathType: ImplementationSpecific
backend:
service:
name: lightrag-site02
port: { number: 9621 }
Backends still set LIGHTRAG_API_PREFIX=/site01 / =/site02.
bun run devAlways open
http://localhost:5173/— root path, no/webui, no/site01— regardless of which scenario below you're in.Vite's dev server serves the SPA at its own root (
/) no matter what prefix you configure.VITE_DEV_API_PREFIXonly affects how the SPA composes API URLs after the page is loaded, and which paths the dev proxy intercepts; it does not change the URL you type in the address bar. Trying to accesslocalhost:5173/site01/webui/works (Vite's SPA fallback returns the sameindex.html), but it's not the canonical entry point and only differs cosmetically in the address bar.This is the deliberate consequence of
base: './'invite.config.ts— the same setting that makes one production build reusable across any number of reverse-proxy mount points. Tying the dev URL to a prefix would force the build to bake the prefix back in.
The dev server mirrors production injection: it serves index.html via the same transformIndexHtml mechanism the FastAPI server uses at request time, so the SPA reads window.__LIGHTRAG_CONFIG__ in dev exactly the way it does in prod. Only two environment variables matter:
| Variable | Purpose | Where it lives |
|---|---|---|
VITE_BACKEND_URL | Where the dev server forwards proxied API calls. | lightrag_webui/.env* |
VITE_DEV_API_PREFIX | Prefix to simulate (matches the backend LIGHTRAG_API_PREFIX`). Empty → no prefix. | lightrag_webui/.env* |
VITE_DEV_API_PREFIX injects apiPrefix into window.__LIGHTRAG_CONFIG__ in the browser, mirroring the backend behavior. It also serves as a prefix for VITE_API_ENDPOINTS, ensuring correct access to backend APIs. The matching webuiPrefix is derived as ${VITE_DEV_API_PREFIX}/webui/ automatically — you don't need a separate variable for it.
Three scenarios cover everything you'll hit:
The default. Don't set anything beyond the existing .env.development.
Browser ──► localhost:5173 (Vite) ──► localhost:9621 (backend, no prefix)
# lightrag_webui/.env.development (already in repo as sample)
VITE_BACKEND_URL=http://localhost:9621
VITE_API_PROXY=true
VITE_API_ENDPOINTS=/api,/documents,/graphs,/graph,/health,/query,/docs,/redoc,/openapi.json,/login,/auth-status,/auth/verify,/ui/customization,/static
# VITE_DEV_API_PREFIX= ← leave empty
Run:
lightrag-server # in one terminal, no LIGHTRAG_API_PREFIX
cd lightrag_webui && bun run dev # in another; open http://localhost:5173/
You want the SPA to run under /site01 (or whatever production prefix). Set VITE_DEV_API_PREFIX=/site01. Vite injects the matching window.__LIGHTRAG_CONFIG__ and registers prefixed proxy keys; SPA requests like fetch("/site01/documents/foo") are forwarded verbatim to whatever VITE_BACKEND_URL points at. The upstream — local backend or production nginx — is responsible for understanding the prefix.
Browser ──► localhost:5173 (Vite + HMR)
│
│ Vite proxy forwards /site01/* verbatim, no rewrite
▼
VITE_BACKEND_URL ──► upstream that knows /site01
.env.local (gitignored — your personal dev config):
VITE_BACKEND_URL=… # see "Where to point VITE_BACKEND_URL" below
VITE_API_PROXY=true
VITE_API_ENDPOINTS=/api,/documents,/graphs,/graph,/health,/query,/docs,/redoc,/openapi.json,/login,/auth-status,/auth/verify,/ui/customization,/static
VITE_DEV_API_PREFIX=/site01
Run bun run dev and open http://localhost:5173/. HMR is purely local — the browser only talks to localhost:5173 for SPA assets, no WebSocket-upgrade config needed on any upstream.
VITE_BACKEND_URLTwo options, picked by where the prefix-aware upstream lives. The Vite-side configuration is identical; only this one variable changes.
A. Local backend with LIGHTRAG_API_PREFIX=/site01 (no nginx anywhere) — the simplest setup, two processes on your laptop. Vite's proxy itself plays the role of the reverse proxy.
VITE_BACKEND_URL=http://localhost:9621
# Terminal 1
LIGHTRAG_API_PREFIX=/site01 lightrag-server
# Terminal 2
cd lightrag_webui && bun run dev
The backend's FastAPI root_path=/site01 accepts the prefixed form natively (Starlette's get_route_path() strips root_path from the request path before matching), so no extra rewriting is needed on either side.
B. Real (remote) backend reached through its production nginx — useful when the actual backend has data / configs that are painful to reproduce locally. nginx already strips /site01/ before forwarding to the backend; the dev frontend benefits without changing anything in production.
VITE_BACKEND_URL=https://prod.example.com # or http://10.0.0.5 — the nginx URL
The production nginx and backend stay exactly as they are. The flow becomes:
SPA fetch /site01/documents/foo
→ Vite forwards to https://prod.example.com/site01/documents/foo
→ nginx matches /site01/, strips it, forwards /documents/foo to backend
→ backend serves it
VITE_BACKEND_URL does not include /site01Vite forwards the request path verbatim (no rewrite). The browser already emits /site01/documents/foo, so the URL Vite sends upstream is ${VITE_BACKEND_URL}/site01/documents/foo. If you set VITE_BACKEND_URL=https://prod.example.com/site01 you would get https://prod.example.com/site01/site01/documents/foo — a duplicated prefix that both nginx and the backend reject. Always point VITE_BACKEND_URL at the upstream root.
proxy: { ..., secure: false } in vite.config.ts to skip cert validation when targeting a staging proxy with a non-public cert.LIGHTRAG_API_KEY, log in via the dev SPA exactly as you would in prod — the auth token flows through the proxy unchanged.localhost:5173. If they appear, check that changeOrigin: true is in effect (it is, by default in vite.config.ts).| Scenario | VITE_BACKEND_URL | VITE_DEV_API_PREFIX | Upstream the dev proxy talks to | Open in browser |
|---|---|---|---|---|
| 1. Default single-instance dev | http://localhost:9621 | unset | local backend, no prefix | http://localhost:5173/ |
| 2A. Simulate a prefix locally (no nginx) | http://localhost:9621 | /site01 | local backend with LIGHTRAG_API_PREFIX=/site01 | http://localhost:5173/ |
| 2B. Hit a real backend through its production nginx | https://prod.example.com | /site01 | remote nginx that already strips /site01/ | http://localhost:5173/ |
Rows 2A and 2B share everything except VITE_BACKEND_URL — the choice is purely "is the prefix-aware upstream on my laptop or in production?".
The "Open in browser" column is always http://localhost:5173/ — that is the entry point in every dev scenario. What changes between rows is where the API traffic ultimately lands; the SPA itself is always served from the dev server's root.
The base URL must end with /. Accessing /site01/webui (no trailing slash) makes the browser resolve ./assets/foo.js against /site01/, which 404s. The server already redirects the no-slash form to the
slash form; verify the redirect is reaching nginx (check X-Forwarded-Prefix and that nginx uses proxy_pass http://…/ with the trailing slash).
apiPrefix is empty in window.__LIGHTRAG_CONFIG__ after deployView the page source. If you see the literal placeholder <!-- __LIGHTRAG_RUNTIME_CONFIG__ --> instead of an injected <script> tag, the request did not go through SmartStaticFiles — double-check that lightrag/api/webui/index.html exists in the running container and that the WebUI mount succeeded (the server logs Admin WebUI mounted at <path> at startup).
bun run dev proxy returns 404 with VITE_DEV_API_PREFIX setConfirm the backend is also running with the matching LIGHTRAG_API_PREFIX. The dev proxy forwards prefixed paths verbatim; if the backend has no prefix configured, it does not register routes under that path.
Don't build the frontend — lightrag/api/webui/index.html will not exist and the server will skip the WebUI mount, redirecting / and the WebUI path to /docs instead (or, when ENABLE_API_DOCS=false also disables the docs, answering them with a small JSON service-info payload whose health_url honors the configured prefix). The /workspace entry degrades separately: when workspace.html is missing (e.g. an older prebuilt bundle), /webui stays fully functional while /workspace answers with a fixed JSON service-info payload that never redirects to or mentions the API docs; with LIGHTRAG_DEFAULT_UI=workspace, / follows that same workspace degradation instead of rerouting to /webui/ or /docs. The runtime-config injection is purely opt-in via the existence of the build artifact. The /docs,/redoc,/openapi.json,/static entries in the VITE_API_ENDPOINTS examples above stay valid with docs disabled — those routes simply return 404 through the proxy.