Skip to content

Deploying

One command

./deploy.sh              # backend + celery + celery-beat
./deploy.sh frontend     # ...and the frontend too

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:

docker compose logs celery --tail=5
docker compose exec celery celery -A app.tasks inspect active

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 idempotent IF NOT EXISTS statements as no-ops. This removed a deadlock that used to kill a worker on every boot.
  • A Redis SETNX lock (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.