Infrastructure and service deployment for the server that hosts the RAG stack (a
Linux box reached over SSH; set its host in deploy/deploy.env).
Day-to-day ops live in
RUNBOOK.md— deploy, rollback, restart, re-index, key rotation, backup/ restore, disk reclaim. This file is the per-component reference. One-command deploy:./deploy/deploy.sh(from the dev repo root) ships source, builds the unified image on the server, installs units, restartsrag-serve, retires its own superseded image (keeping one as:previous), and health-checks.
The deploy target host comes from $DEPLOY_HOST — copy deploy/deploy.env.example to deploy/deploy.env (gitignored)
and set it, or pass it inline (DEPLOY_HOST=myhost ./deploy/deploy.sh); an inline value beats the file.
If the dev box is also the server, use ./deploy/deploy-local.sh — the same steps without the ssh/scp hop (which
would otherwise require the box to hold a key for logging into itself). It is a thin wrapper that sets
DEPLOY_HOST=local and execs deploy.sh, so there is only ever one list of deploy steps to keep correct.
Runs as a rootless Podman container managed by a systemd Quadlet user unit.
| Item | Value |
|---|---|
| Image | docker.io/qdrant/qdrant:v1.18.2 |
| Unit | ~/.config/containers/systemd/qdrant.container → generates qdrant.service (user scope) |
| Data | ~/rag/qdrant/storage (bind mount → /qdrant/storage) |
| Snapshots | ~/rag/qdrant/snapshots (bind mount → /qdrant/snapshots) |
| Ports | 6333 REST, 6334 gRPC |
| Auth | none by default (intended for a trusted/private network). To enable: set QDRANT__SERVICE__API_KEY in the unit + QDRANT_API_KEY on clients. |
| Boot | requires sudo loginctl enable-linger <user> |
# on the server
sudo loginctl enable-linger <user>
mkdir -p ~/rag/qdrant/storage ~/rag/qdrant/snapshots ~/.config/containers/systemd
podman pull docker.io/qdrant/qdrant:v1.18.2
# copy deploy/qdrant.container → ~/.config/containers/systemd/qdrant.container
systemctl --user daemon-reload
systemctl --user start qdrant.servicesystemctl --user status qdrant.service
systemctl --user restart qdrant.service
journalctl --user -u qdrant.service -fdeploy.sh retires rag's own superseded image itself. deploy/prune-images.sh
removes every unused image on the host — run it only on a box dedicated to rag.
One image, localhost/rag:latest, built on the server from Containerfile and used by every unit here. It carries the
three binaries (rag-ingest, rag-serve, rag-mcp) and the built ledger UI; the unit names the binary, so there is no
ENTRYPOINT.
Containerfile has two independent build stages and a slim runtime:
| Stage | Base | Produces |
|---|---|---|
| SPA | node:22-bookworm-slim |
web/app/dist via npm ci && npm run build over the web/ workspace |
| Binaries | rust:1.94-bookworm |
rag-ingest, rag-serve, rag-mcp (release) |
| Runtime | debian:bookworm-slim |
the three binaries + the bundle at /usr/local/share/rag/web, plus git, CA certs and curl |
Neither build stage copies the other's inputs, so a UI change does not recompile the workspace and a crate change does
not re-run npm ci. The bundle's destination is fixed by agreement with rag-serve.container's
RAG_SERVER__WEB_ROOT — change one, change the other. Nothing from the Node stage other than dist reaches the runtime
image, so node_modules and the Node toolchain are not shipped.
The server needs the npm registry as well as crates.io at build time. Neither node_modules/ nor dist/ is
rsynced — the build makes both from the committed package-lock.json.
The indexer (rag-ingest) runs on the server as a container, driven by a systemd timer that polls GitHub every
15 min. No public endpoint is needed.
| Item | Value |
|---|---|
| Image | localhost/rag:latest (unified image, rag-ingest binary; built on the server from Containerfile) |
| Env file | ~/.config/rag/env (chmod 600): VOYAGE_API_KEY, GITHUB_TOKEN, and RAG_GITHUB__ACCOUNT (required — the GitHub user/org whose repos are indexed; the unit ships no default, so an empty account purges the corpus) |
| Corpus volume | ~/rag/corpus → /work/corpus (shallow clones persist between runs) |
| Units | ~/.config/systemd/user/rag-sync.{service,timer} (rootless) |
| Schedule | every 15 min (OnCalendar=*:0/15, Persistent=true, 30 s jitter) |
./deploy/deploy.sh does all of this (and the serve service). Manually, the indexer pieces are:
# from dev: ship source (no target/.git/.env/node_modules/dist), then build on the server
rsync -az --exclude target --exclude .git --exclude .env --exclude corpus \
--exclude node_modules --exclude dist ./ "$DEPLOY_HOST:rag/src/"
ssh "$DEPLOY_HOST" 'cd ~/rag/src && podman build -t localhost/rag:latest -f Containerfile .'
# env file (once): ~/.config/rag/env -> VOYAGE_API_KEY=... / GITHUB_TOKEN=... / RAG_GITHUB__ACCOUNT=your-github-account
# install + enable the timer (copy deploy/rag-sync.{service,timer} -> ~/.config/systemd/user/)
ssh "$DEPLOY_HOST" 'systemctl --user daemon-reload && systemctl --user enable --now rag-sync.timer'systemctl --user list-timers rag-sync.timer
systemctl --user start rag-sync.service # run a sync now
journalctl --user -u rag-sync.service -frag-serve runs on the server as a rootless Quadlet container, host-networked so it binds :17793 and reaches
Qdrant at localhost:6334. It needs only Qdrant + the Voyage key (not the corpus files). It also serves the ledger
web UI at / on that same port, from a bundle baked into the image.
| Item | Value |
|---|---|
| Image | localhost/rag:latest (rag-serve binary) |
| Unit | ~/.config/containers/systemd/rag-serve.container → generates rag-serve.service |
| Secrets | ~/.config/rag/env (VOYAGE_API_KEY; QDRANT_API_KEY and ANTHROPIC_API_KEY optional) |
| Config | built-in defaults + RAG_* env (no rag.toml shipped); RAG_QDRANT__URL=http://localhost:6334 |
| Port | 17793 (host networking) |
| Health | GET /health → ok; GET /info → collection stats + models + retrieval_ready/summarise_ready (no secrets) |
| Summarise | POST /ledger/{id}/summarise — needs ANTHROPIC_API_KEY; optional, and 503s without it. Nothing is cached: one billed call per press. |
| Web UI | http://<server>:17793/ — from RAG_SERVER__WEB_ROOT=/usr/local/share/rag/web |
| Saved prompts | GET /prompts, POST /prompts/save, POST /prompts/delete — the Summarise tab's prompt library. ~/rag/prompts → /prompts (RAG_SERVER__PROMPTS_DIR), one .md file per prompt. Unset/empty dir = only the built-in prompt; the write routes 503. |
| Boot | auto-starts via linger (Quadlet WantedBy=default.target) |
The UI shares the API's origin, so there is no CORS to configure — and there should not be. RAG_SERVER__WEB_ROOT is
optional: unset it, or point it at a directory with no index.html, and rag-serve logs a warning and serves the JSON
API alone. The API paths (/health, /info, /search, /reindex, /ledger*, /prompts*) are excluded from the UI's
single-page fallback, so a mistyped endpoint still answers a JSON 404 rather than an HTML document.
The saved prompts directory is bind-mounted rather than baked in, so prompts survive an image rebuild and can be
managed with a text editor: ~/rag/prompts/Terse.md is the prompt named "Terse". ~/rag/ and deliberately not
~/.config/rag/, which holds the secrets env file — mounting that directory into the container would put the secrets on
the container filesystem for no gain. The built-in default prompt is a constant in the binary, is served to the UI by
/info, and is never written to disk: an empty directory is a fallback to it, not a missing file. Note that
/prompts/save and /prompts/delete are the only routes in the service that write a file, on a port with no auth — the
name allow-list and containment checks that make that acceptable are in crates/serve/src/prompts.rs.
Because the bundle lives in the image, a UI change ships by redeploy, not by restart — ./deploy/deploy.sh rebuilds
the image; systemctl --user restart just re-runs the one already there.
rag-mcp is not deployed here — it's a stdio MCP server run per-client on the box where Claude Code runs (build
locally with cargo build --release -p rag-mcp; register via .mcp.json). See RUNBOOK.md.
deploy/backup-qdrant.sh (run on the server) snapshots rag_corpus via the Qdrant API and keeps the last 7 tarballs
under ~/rag/backups. Restore steps in RUNBOOK.md. The index is also fully rebuildable from GitHub via
rag-sync — a snapshot just skips the re-embed.
Created from the dev machine, idempotently:
cargo run -p rag-ingest -- initVector size 1024 / cosine, int8 scalar quantization, payload indexes on
corpus, repo, path, language, content_hash.