Deploying¶
What you need¶
| Component | Required | Notes |
|---|---|---|
| PostgreSQL | yes | With pgvector if using it as the vector store |
| Object storage | yes | A directory is fine for a single node |
| RabbitMQ | for ingestion | Without it, uploads stay queued |
| Embedding endpoint | for retrieval | Any OpenAI-compatible API |
oauth2-proxy |
for multi-user | Not needed with auth off |
| Qdrant | optional | Alternative to pgvector |
Running it with Compose¶
cp deploy/compose/env.example deploy/compose/.env
# fill in the passwords and your model endpoint, then:
docker compose -f deploy/compose/compose.yaml up --wait
deploy/compose/scripts/smoke.sh
The Compose profile is for one person, and stays that way
It runs with authentication off: every request is the same fixed local
user, so anyone who can reach the published ports is that user. Ports
are bound to 127.0.0.1 for that reason.
Multi-user Primer is the Kubernetes deployment, which is where the ingress and the identity provider live. A proxy bolted onto Compose would look multi-user while one forgotten published port made the authentication decorative.
Changing a password later needs the volume gone
PostgreSQL reads POSTGRES_PASSWORD only when it first initialises its
data directory. Editing it afterwards leaves the stored password
unchanged, and every service then fails with password authentication
failed for user "primer".
That deletes the database along with the volume, so only do it on a stack you can recreate.
Published images¶
Each push to main publishes three images to GHCR, so nothing has to be
built locally:
| Image | Contains |
|---|---|
ghcr.io/isaiah-harville/primer-api |
Control, Chat, Retrieval |
ghcr.io/isaiah-harville/primer-worker |
The ingestion worker |
ghcr.io/isaiah-harville/primer-web |
The web app |
Tagged latest and by commit SHA. Pull requests build them but do not push:
a broken Dockerfile fails review rather than after merge, and an image
pushed from a pull request would let anyone who can open one publish under
this repository's name.
The Compose profile builds from source rather than pulling, because a local checkout is usually one you are changing.
The images¶
Two Python images, not one.
| Image | Base | Contains |
|---|---|---|
Dockerfile.python |
Alpine + uv | Control, Chat, Retrieval |
Dockerfile.worker |
Debian slim + uv | The ingestion worker |
Dockerfile.gui |
Node slim | The web app |
The split is not tidiness. Docling's layout models run on torch, and torch publishes no musl wheels — so the worker needs a glibc base and a machine learning stack, while the three API services need neither. Sharing one image would put roughly two gigabytes into every service that never imports it.
Both Python images are a single stage. A build/runtime split earns its keep
when the build needs compilers or caches the runtime should not have; here
everything installs from a wheel and uv's cache is a mount rather than a
layer. Each uv sync writes a complete virtualenv that stays in its layer,
so a separate dependency-install layer nearly doubled the API image.
Running the processes directly¶
uv run --package primer-control uvicorn primer_control.app:create_app --factory
uv run --package primer-retrieval uvicorn primer_retrieval.app:create_app --factory
uv run --package primer-chat uvicorn primer_chat.app:create_app --factory
uv run --package primer-ingestion celery -A primer_ingestion.worker worker \
-Q ingestion.parse,ingestion.embed,ingestion.index,ingestion.delete
Workers can be split by queue. Parsing is CPU-heavy and holds whole documents in memory; embedding is mostly waiting on a network call. Running them as separate deployments lets each scale on what actually constrains it.
Order of operations¶
- Create the database and run migrations.
- Start Retrieval, so workers have somewhere to send passages.
- Start Control.
- Start workers.
Starting out of order is not harmful — jobs wait, and stages retry — but
documents will sit in queued until the chain is complete.
Putting it behind a proxy¶
This applies to the Kubernetes deployment, not to Compose.
Primer trusts the identity headers it receives.
Bind so that only the proxy can reach Control
If the Control API is reachable directly, anyone who can reach it can set the identity headers themselves and act as any user. This is the single most important thing to get right in a shared deployment.
The proxy must also not route /internal from outside. Those endpoints are
guarded by a service credential, but they are not meant to be exposed.
Health checks¶
Both services expose /health/live. Control also exposes /health/ready,
which reports unready when it cannot reach PostgreSQL.
Use liveness for restarts and readiness for load-balancer membership: a Control instance that cannot reach its database should stop receiving traffic without being killed and restarted in a loop.
Scaling¶
Every service is stateless and can run several replicas. The design assumes it:
- Two workers handed the same message cannot both do the work; the loser is told the stage is already claimed.
- A worker that dies mid-stage has its lease expire, and another picks the job up.
- Concurrent uploads of identical bytes converge on one stored object.