Deploying¶
One command¶
It builds, recreates, waits up to 150 seconds for nothing to be restarting or unhealthy, prints the status table, and exits non-zero with the last 30 lines of logs if anything is still wrong.
Why it exists¶
backend, celery and celery-beat all build from ./backend, so a change
to backend code makes all three stale — but only backend was ever rebuilt. On
2026-09-22 that left the worker running a three-day-old image with four service
modules missing. It crash-looped 69 times and every queued AI job silently
failed.
One command, so they cannot drift apart again.
A restart is not a deploy¶
docker compose restart reuses the old image. It picks up environment
changes only.
| You changed | You need |
|---|---|
| Backend Python code | ./deploy.sh |
| Frontend source | ./deploy.sh frontend |
backend/.env |
docker compose restart backend celery celery-beat |
frontend/.env |
docker compose restart frontend |
| A Redis-held site setting | Nothing. It is live. |
The frontend is a Vite build inside Docker. Vite minifies function names, so grepping the built JavaScript for a component name finds nothing.
Never restart mid-task¶
Check first:
A PDF extraction that is killed halfway leaves its document stuck at
processing for ever — see Troubleshooting.
In front of it¶
The frontend binds 127.0.0.1:8081. Put Caddy or nginx in front for HTTPS.
Two things the deployment must get right or rate limiting silently collapses into one bucket for the whole site:
- nginx must set
X-Forwarded-For, overwriting it so nothing a client sends for itself survives - uvicorn must run with
--proxy-headers(it does, in the compose command)
Also set client_max_body_size in nginx just above MAX_UPLOAD_SIZE, or a
clear 400 becomes a bare 413 from the proxy.
Database migrations¶
Schema changes go through Alembic. alembic.ini holds no URL —
alembic/env.py injects DATABASE_URL from the container environment.
docker compose exec backend alembic current
docker compose exec backend alembic heads
docker compose exec backend alembic revision --autogenerate -m "add some column"
docker compose exec backend alembic upgrade head
docker compose exec backend alembic downgrade -1
Review the generated file before applying it. --autogenerate does not catch
server-default changes, CHECK constraints, enum additions or data migrations.
Base.metadata.create_all() remains in main.py as a fallback for fresh
deploys. Do not remove it without first generating a complete baseline
migration from the live schema.
The full workflow is in Migrations.
Startup, with four workers¶
Two coordination mechanisms stop the workers racing each other:
- A Postgres advisory lock (
pg_advisory_lock(8472931)) serialises startup DDL. One worker runs it; the others wait and then see the idempotentIF NOT EXISTSstatements as no-ops. This removed a deadlock that used to kill a worker on every boot. - A Redis
SETNXlock (startup:singleton_lock, 300 s) means only one worker starts the scheduler and the embedding backfill.
Stale idle in transaction connections are killed at startup so a DDL
migration cannot hang behind one.