diff --git a/CHANGELOG.md b/CHANGELOG.md
index 10235113..722f211b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,10 @@
# Changelog
## [Unreleased]
+### Added
+
+- `ingester.api.root_path` (and `haiku-ingester serve --root-path`) serves the control plane under a sub-path for reverse-proxying; forwarded to FastAPI/uvicorn `root_path` and reflected in the dashboard's ``.
+
## [0.56.0] - 2026-06-09
### Added
diff --git a/docs/ingester.md b/docs/ingester.md
index b99492dc..1e48d77b 100644
--- a/docs/ingester.md
+++ b/docs/ingester.md
@@ -326,6 +326,32 @@ ingester:
host: 127.0.0.1
port: 8765
auth_token: secret # null → unauthenticated
+ root_path: "" # e.g. /ingester behind a proxy
+```
+
+### Behind a reverse proxy
+
+To serve the control plane under a sub-path (so a reverse proxy can front
+it alongside other services on one origin, e.g. `https://host/ingester/`),
+set `ingester.api.root_path` (or `serve --root-path /ingester`). It is
+forwarded to FastAPI/uvicorn as `root_path` — OpenAPI/`/docs` links become
+prefix-aware — and the dashboard is served with a matching `` so
+its JSON fetches resolve under the prefix. The value is normalized to a
+single leading slash with no trailing slash (`ingester`, `/ingester/` and
+`/` become `/ingester`, `/ingester` and `""`). Strip the prefix at the proxy
+before forwarding; for example, with nginx:
+
+```nginx
+# Redirect the bare prefix to the trailing-slash form so the dashboard's
+# resolves correctly.
+location = /ingester {
+ return 308 /ingester/;
+}
+
+location /ingester/ {
+ rewrite ^/ingester/?(.*)$ /$1 break;
+ proxy_pass http://127.0.0.1:8765;
+}
```
## Operating
diff --git a/haiku_rag_slim/haiku/rag/config/models.py b/haiku_rag_slim/haiku/rag/config/models.py
index 5263eca0..b32c6f8d 100644
--- a/haiku_rag_slim/haiku/rag/config/models.py
+++ b/haiku_rag_slim/haiku/rag/config/models.py
@@ -1,7 +1,7 @@
from pathlib import Path
from typing import Annotated, Literal
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, ConfigDict, Field, field_validator
from haiku.rag.utils import get_default_data_dir
@@ -345,10 +345,34 @@ class WorkerConfig(BaseModel):
class APIConfig(BaseModel):
"""HTTP control plane settings for the ingester."""
+ # Validate on assignment so CLI overrides (e.g. --root-path) run the same
+ # normalization as values parsed from the config file.
+ model_config = ConfigDict(validate_assignment=True)
+
enabled: bool = True
host: str = "127.0.0.1"
port: int = 8765
auth_token: str | None = None
+ root_path: str = Field(
+ default="",
+ description=(
+ "Base path the control plane is served under when reverse-proxied "
+ "behind a sub-path (e.g. '/ingester'). Empty serves at the root. "
+ "Forwarded to FastAPI/uvicorn as root_path and used to set the "
+ "dashboard's so its fetches are prefix-aware."
+ ),
+ )
+
+ @field_validator("root_path")
+ @classmethod
+ def _normalize_root_path(cls, value: str) -> str:
+ """Normalize to '' (root) or a single leading-slash, no-trailing-slash
+ prefix, so 'ingester', '/ingester/' and '/' become '/ingester',
+ '/ingester' and ''."""
+ trimmed = value.strip().rstrip("/")
+ if trimmed and not trimmed.startswith("/"):
+ trimmed = "/" + trimmed
+ return trimmed
class _SourceBase(BaseModel):
diff --git a/haiku_rag_slim/haiku/rag/ingester/api/routes/dashboard.py b/haiku_rag_slim/haiku/rag/ingester/api/routes/dashboard.py
index 1cf87ddb..4b7ce76c 100644
--- a/haiku_rag_slim/haiku/rag/ingester/api/routes/dashboard.py
+++ b/haiku_rag_slim/haiku/rag/ingester/api/routes/dashboard.py
@@ -1,17 +1,26 @@
from pathlib import Path
-from fastapi import APIRouter
-from fastapi.responses import FileResponse
+from fastapi import APIRouter, Request
+from fastapi.responses import HTMLResponse
router = APIRouter(tags=["dashboard"])
_INDEX_HTML_PATH = Path(__file__).resolve().parent.parent / "static" / "index.html"
+_INDEX_HTML = _INDEX_HTML_PATH.read_text(encoding="utf-8")
@router.get("/", include_in_schema=False)
-async def dashboard() -> FileResponse:
+async def dashboard(request: Request) -> HTMLResponse:
"""Serve the operator dashboard. Static page that polls /health, /sources,
/stats and /jobs from the browser. Auth happens via the bearer header the
JS attaches to its fetches — the dashboard route itself is unauthenticated
- so an operator can open it in a browser and paste the token on demand."""
- return FileResponse(_INDEX_HTML_PATH, media_type="text/html")
+ so an operator can open it in a browser and paste the token on demand.
+
+ A ```` matching the server's ``root_path`` is injected so the
+ page's relative fetches resolve correctly whether the control plane is
+ served at the root or reverse-proxied under a sub-path (e.g. ``/ingester/``).
+ """
+ root_path = request.scope.get("root_path", "")
+ base_href = f"{root_path}/" if root_path else "/"
+ html = _INDEX_HTML.replace("
", f'\n ', 1)
+ return HTMLResponse(html)
diff --git a/haiku_rag_slim/haiku/rag/ingester/api/server.py b/haiku_rag_slim/haiku/rag/ingester/api/server.py
index 5985f691..bafa7b0e 100644
--- a/haiku_rag_slim/haiku/rag/ingester/api/server.py
+++ b/haiku_rag_slim/haiku/rag/ingester/api/server.py
@@ -34,8 +34,15 @@ def build_app(
state: APIState,
*,
auth_token: str | None = None,
+ root_path: str = "",
) -> FastAPI:
- """Construct the ingester's FastAPI control plane."""
+ """Construct the ingester's FastAPI control plane.
+
+ ``root_path`` serves the control plane under a base path when it is
+ reverse-proxied behind a sub-path (e.g. ``/ingester``). It is forwarded to
+ FastAPI so generated URLs (OpenAPI/docs) and the dashboard's ````
+ are prefix-aware; empty serves at the root.
+ """
from haiku.rag.ingester.api.routes import (
config as config_route,
)
@@ -54,6 +61,7 @@ def build_app(
title="haiku-ingester",
description="Control plane for the haiku.rag production ingester.",
version="1",
+ root_path=root_path,
)
app.state.api_state = state
app.state.auth_token = auth_token
diff --git a/haiku_rag_slim/haiku/rag/ingester/api/static/index.html b/haiku_rag_slim/haiku/rag/ingester/api/static/index.html
index 42fcae7b..039bd005 100644
--- a/haiku_rag_slim/haiku/rag/ingester/api/static/index.html
+++ b/haiku_rag_slim/haiku/rag/ingester/api/static/index.html
@@ -634,13 +634,13 @@
try {
const [health, sources, providers, stats, active, dead, recent] =
await Promise.all([
- fetchJson("/health"),
- fetchJson("/sources"),
- fetchJson("/providers"),
- fetchJson("/stats"),
- fetchJson("/jobs?status=claimed&limit=20"),
- fetchJson("/jobs?status=dead&limit=10"),
- fetchJson("/jobs?status=succeeded&limit=10"),
+ fetchJson("health"),
+ fetchJson("sources"),
+ fetchJson("providers"),
+ fetchJson("stats"),
+ fetchJson("jobs?status=claimed&limit=20"),
+ fetchJson("jobs?status=dead&limit=10"),
+ fetchJson("jobs?status=succeeded&limit=10"),
]);
setHealthDot(false);
let detail = `${health.worker_count} workers · ${health.poller_count} pollers`;
@@ -676,16 +676,16 @@
// Action handlers (called from inline onclick — kept on window).
window.cancelJob = async (id) => {
if (!confirm(`Cancel job ${id.slice(0, 8)}…?`)) return;
- try { await deleteJson(`/jobs/${encodeURIComponent(id)}`); } catch (e) {}
+ try { await deleteJson(`jobs/${encodeURIComponent(id)}`); } catch (e) {}
refresh();
};
window.retryJob = async (id) => {
- try { await postJson(`/jobs/${encodeURIComponent(id)}/retry`); } catch (e) {}
+ try { await postJson(`jobs/${encodeURIComponent(id)}/retry`); } catch (e) {}
refresh();
};
window.refreshSource = async (sid) => {
try {
- await postJson(`/sources/${encodeURIComponent(sid)}/refresh`);
+ await postJson(`sources/${encodeURIComponent(sid)}/refresh`);
} catch (e) {}
refresh();
};
@@ -801,7 +801,7 @@
async function loadDatabase() {
$("db-body").innerHTML = 'loading…
';
try {
- renderDatabase(await fetchJson("/database"));
+ renderDatabase(await fetchJson("database"));
dbLoaded = true;
} catch (e) {
$("db-body").innerHTML = `error: ${escapeHtml(e.message)}
`;
@@ -812,7 +812,7 @@
async function loadConfig() {
$("config-body").innerHTML = 'loading…
';
try {
- const data = await fetchJson("/config");
+ const data = await fetchJson("config");
$("config-body").innerHTML = '';
$("config-body").querySelector("pre").textContent = data.yaml;
configLoaded = true;
diff --git a/haiku_rag_slim/haiku/rag/ingester/app.py b/haiku_rag_slim/haiku/rag/ingester/app.py
index 2e0d283f..a22f82c3 100644
--- a/haiku_rag_slim/haiku/rag/ingester/app.py
+++ b/haiku_rag_slim/haiku/rag/ingester/app.py
@@ -255,17 +255,25 @@ class IngesterApp:
)
if ingester_cfg.api.auth_token is None:
logger.warning("API auth_token is unset — control plane is unauthenticated")
- app = build_app(state, auth_token=ingester_cfg.api.auth_token)
+ app = build_app(
+ state,
+ auth_token=ingester_cfg.api.auth_token,
+ root_path=ingester_cfg.api.root_path,
+ )
config = uvicorn.Config(
app,
host=ingester_cfg.api.host,
port=ingester_cfg.api.port,
+ root_path=ingester_cfg.api.root_path,
log_level="info",
access_log=_api_access_log_enabled(),
lifespan="off",
)
server = uvicorn.Server(config)
logger.info(
- "API listening on %s:%d", ingester_cfg.api.host, ingester_cfg.api.port
+ "API listening on %s:%d%s",
+ ingester_cfg.api.host,
+ ingester_cfg.api.port,
+ ingester_cfg.api.root_path,
)
return asyncio.create_task(server.serve()), server
diff --git a/haiku_rag_slim/haiku/rag/ingester/cli.py b/haiku_rag_slim/haiku/rag/ingester/cli.py
index 147d5f95..82f758f1 100644
--- a/haiku_rag_slim/haiku/rag/ingester/cli.py
+++ b/haiku_rag_slim/haiku/rag/ingester/cli.py
@@ -154,6 +154,13 @@ def serve(
"--port",
help="Bind the HTTP control plane to PORT (overrides ingester.api.port).",
),
+ root_path: str | None = typer.Option(
+ None,
+ "--root-path",
+ help="Serve the control plane under a base path so it can be reverse-"
+ "proxied behind a sub-path, e.g. /ingester (overrides "
+ "ingester.api.root_path).",
+ ),
no_api: bool = typer.Option(
False,
"--no-api",
@@ -167,6 +174,8 @@ def serve(
app_config.ingester.api.host = host
if port is not None:
app_config.ingester.api.port = port
+ if root_path is not None:
+ app_config.ingester.api.root_path = root_path
db_path = _resolve_db_path(app_config, db)
app = IngesterApp(config=app_config, db_path=db_path)
asyncio.run(app.serve(api=not no_api))
diff --git a/tests/ingester/test_api.py b/tests/ingester/test_api.py
index cecd24d7..fdf5b001 100644
--- a/tests/ingester/test_api.py
+++ b/tests/ingester/test_api.py
@@ -23,8 +23,10 @@ def state(jobs, sync):
)
-def _client(state, *, auth_token: str | None = None) -> httpx.AsyncClient:
- app = build_app(state, auth_token=auth_token)
+def _client(
+ state, *, auth_token: str | None = None, root_path: str = ""
+) -> httpx.AsyncClient:
+ app = build_app(state, auth_token=auth_token, root_path=root_path)
return httpx.AsyncClient(
transport=ASGITransport(app=app), base_url="http://testserver"
)
@@ -627,10 +629,11 @@ async def test_dashboard_served_unauthenticated(state):
assert "text/html" in resp.headers["content-type"]
body = resp.text
assert "haiku-ingester · status" in body
- # The JS calls the JSON endpoints; sanity-check it's wired up.
- assert "/stats" in body
- assert "/sources" in body
- assert "/jobs?status=claimed" in body
+ # The JS calls the JSON endpoints with base-href-relative paths (no leading
+ # slash) so the page works behind a sub-path; sanity-check it's wired up.
+ assert 'fetchJson("stats")' in body
+ assert 'fetchJson("sources")' in body
+ assert 'fetchJson("jobs?status=claimed&limit=20")' in body
# Op badge helper is present so DELETE rows render distinctly.
assert "opBadge" in body
@@ -662,12 +665,44 @@ async def test_dashboard_wires_database_and_config_panels(state):
body = resp.text
assert 'id="db-panel"' in body
assert 'id="config-panel"' in body
- assert "/database" in body
- assert "/config" in body
+ assert 'fetchJson("database")' in body
+ assert 'fetchJson("config")' in body
assert "loadDatabase" in body
assert "loadConfig" in body
+@pytest.mark.asyncio
+async def test_dashboard_base_href_defaults_to_root(state):
+ """With no root_path the dashboard's is the origin root, so its
+ relative fetches resolve at the top level exactly as before."""
+ async with _client(state) as client:
+ resp = await client.get("/")
+
+ assert resp.status_code == 200
+ assert '' in resp.text
+
+
+@pytest.mark.asyncio
+async def test_dashboard_base_href_reflects_root_path(state):
+ """When served under a root_path (reverse-proxied sub-path), the injected
+ carries the prefix so the relative fetches hit /ingester/..."""
+ async with _client(state, root_path="/ingester") as client:
+ resp = await client.get("/")
+
+ assert resp.status_code == 200
+ assert '' in resp.text
+
+
+@pytest.mark.asyncio
+async def test_api_routes_unchanged_under_root_path(state):
+ """root_path only affects URL generation/base-href; the routes themselves
+ still answer at their declared paths (the proxy strips the prefix)."""
+ async with _client(state, root_path="/ingester") as client:
+ resp = await client.get("/health")
+
+ assert resp.status_code == 200
+
+
# --- config ---
diff --git a/tests/ingester/test_cli.py b/tests/ingester/test_cli.py
index 093d9b6c..1e69cdad 100644
--- a/tests/ingester/test_cli.py
+++ b/tests/ingester/test_cli.py
@@ -82,6 +82,25 @@ def test_serve_passes_host_and_port(monkeypatch):
assert captured["config"].ingester.api.port == 9999
+def test_serve_passes_root_path(monkeypatch):
+ fake = AsyncMock()
+ captured = {}
+
+ def _capture(**kwargs):
+ captured.update(kwargs)
+ return fake
+
+ monkeypatch.setattr("haiku.rag.ingester.cli.IngesterApp", _capture)
+
+ result = runner.invoke(
+ cli, ["serve", "--db", "x.lancedb", "--root-path", "/ingester/"]
+ )
+
+ assert result.exit_code == 0
+ # Assignment is normalized (trailing slash stripped) via validate_assignment.
+ assert captured["config"].ingester.api.root_path == "/ingester"
+
+
# --- queue init / migrate ---
diff --git a/tests/ingester/test_config.py b/tests/ingester/test_config.py
index 0510a53d..370ec377 100644
--- a/tests/ingester/test_config.py
+++ b/tests/ingester/test_config.py
@@ -5,6 +5,7 @@ import yaml
from pydantic import ValidationError
from haiku.rag.config import (
+ APIConfig,
AppConfig,
FSSourceConfig,
HTTPSourceConfig,
@@ -21,6 +22,34 @@ def test_default_ingester_config_has_sane_values():
assert cfg.workers.retry.max_attempts == 5
assert cfg.api.enabled is True
assert cfg.api.port == 8765
+ assert cfg.api.root_path == ""
+
+
+@pytest.mark.parametrize(
+ "raw, expected",
+ [
+ ("", ""),
+ ("/", ""),
+ ("ingester", "/ingester"),
+ ("/ingester", "/ingester"),
+ ("/ingester/", "/ingester"),
+ (" /ingester/ ", "/ingester"),
+ ],
+)
+def test_api_root_path_normalized(raw, expected):
+ cfg = APIConfig(root_path=raw)
+
+ assert cfg.root_path == expected
+
+
+def test_api_root_path_normalized_on_assignment():
+ """validate_assignment ensures CLI overrides (--root-path) get the same
+ normalization as values parsed from the config file."""
+ cfg = APIConfig()
+
+ cfg.root_path = "ingester/"
+
+ assert cfg.root_path == "/ingester"
def test_discriminator_picks_fs_source():