# Single-Server Multi-Site Deployment
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](./DockerDeployment.md). For frontend build
> mechanics in general, see [FrontendBuildGuide.md](./FrontendBuildGuide.md).
---
## TL;DR
- Set `LIGHTRAG_API_PREFIX` per-instance, on the **backend only**. The WebUI is always mounted at `/webui` (not configurable).
- Build the WebUI **once**. The same artifacts work under any reverse-proxy prefix.
- Point your reverse proxy at each backend, stripping the site prefix before forwarding.
```bash
# 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
```
---
## Why "build once, deploy many"
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:
- **Asset URLs** in `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.
- **API base URL** and **in-app links** read their prefix from `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.
---
## How runtime prefix injection works
Each request for `index.html` goes through `SmartStaticFiles` in `lightrag/api/lightrag_server.py`, which:
1. Reads the static `index.html` produced by `bun run build`.
2. Looks for the placeholder comment ``.
3. Replaces it with
``,
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.
---
## One backend variable, that's it
| 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`. |
The WebUI is always mounted at `/webui` server-side. `window.__LIGHTRAG_CONFIG__.webuiPrefix` is computed as `LIGHTRAG_API_PREFIX + "/webui/"` and injected for the SPA — you do **not** set it yourself.
There are no longer any frontend `VITE_API_PREFIX` / `VITE_WEBUI_PREFIX` variables. Setting them has no effect (they are ignored by the build).
### Forwarding modes: strip and verbatim both work
After setting `LIGHTRAG_API_PREFIX=/site01`, the backend resolves all routes correctly under either forwarding style:
- **Strip** — proxy removes the prefix, backend sees `/webui/` and `/documents/foo`. The nginx example below uses this style.
- **Verbatim** — proxy forwards the request unchanged, backend sees `/site01/webui/` and `/site01/documents/foo`. The Vite dev flow ([Scenario 2](#scenario-2--simulate-a-site-prefix)) 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.
---
## End-to-end example: two sites behind one nginx
### Instance configuration
`site01.env`:
```bash
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`:
```bash
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 …
```
### docker-compose.yml (one image, two services)
```yaml
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"
```
### nginx config
```nginx
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.
### What each layer sees
| 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`) |
---
## Single-image Docker recipe
The `Dockerfile` builds the WebUI once, with no prefix:
```dockerfile
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:
```bash
# 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
```
### Kubernetes Ingress equivalent
```yaml
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`.
---
## Local development with `bun run dev`
> **Always 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_PREFIX` only 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 access `localhost:5173/site01/webui/` works (Vite's SPA fallback returns the same `index.html`), but it's not the canonical entry point and only differs cosmetically in the address bar.
>
> This is the deliberate consequence of `base: './'` in [`vite.config.ts`](../lightrag_webui/vite.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:
### Scenario 1 — single-instance dev (no prefix, no proxy)
The default. Don't set anything beyond the existing `.env.development`.
```
Browser ──► localhost:5173 (Vite) ──► localhost:9621 (backend, no prefix)
```
```bash
# 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,/static
# VITE_DEV_API_PREFIX= ← leave empty
```
Run:
```bash
lightrag-server # in one terminal, no LIGHTRAG_API_PREFIX
cd lightrag_webui && bun run dev # in another; open http://localhost:5173/
```
### Scenario 2 — simulate a site prefix
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):
```bash
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,/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.
#### Where to point `VITE_BACKEND_URL`
Two 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.
```bash
VITE_BACKEND_URL=http://localhost:9621
```
```bash
# 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.
```bash
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
```
#### Why `VITE_BACKEND_URL` does **not** include `/site01`
Vite 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**.
#### Common pitfalls (mostly relevant to option B)
- **HTTPS upstream + self-signed cert**: Vite's proxy rejects by default. Set `proxy: { ..., secure: false }` in `vite.config.ts` to skip cert validation when targeting a staging proxy with a non-public cert.
- **Auth required**: if the upstream requires `LIGHTRAG_API_KEY`, log in via the dev SPA exactly as you would in prod — the auth token flows through the proxy unchanged.
- **CORS errors**: shouldn't happen because the browser sees same-origin requests to `localhost:5173`. If they appear, check that `changeOrigin: true` is in effect (it is, by default in `vite.config.ts`).
### Quick decision matrix
| 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.
---
## Troubleshooting
### Asset URLs 404 when accessing the WebUI
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 deploy
View the page source. If you see the literal placeholder `` instead of an injected `