Production deployment
A single host plus a TLS reverse proxy in front of the frontend. Kubernetes is deliberately out of scope until a real scaling need exists.
Shape
Internet → Caddy (TLS) → frontend:3031 → api:8000 → postgres · redis
↑
worker
The frontend is the only publicly reachable service. It proxies /api/* over the
internal Docker network, which is what keeps auth cookies first-party and leaves the API,
the database, and Redis unexposed.
Before you expose it
Work through the hardening checklist. The items that most commonly get missed:
ENVIRONMENT=production # secure cookies, HSTS, /docs disabled
JWT_SECRET_KEY=<openssl rand -hex 32>
ENCRYPTION_KEY=<openssl rand -hex 32> # set explicitly; do not let it derive
REQUIRE_EMAIL_VERIFICATION=true
DEFAULT_MONTHLY_TOKEN_LIMIT=50000 # applied to every NEW account
RESEARCH_RATE_LIMIT_PER_HOUR=10 # 0 = unlimited, and 0 is the default
CHAT_RATE_LIMIT_PER_HOUR=60
The three limits default to unlimited, which is right for a single-tenant self-host and wrong for anything public. A public deployment should require bring-your-own-key from everyone and leave the server key unset, so there is no shared key to drain.
Setting
MAX_COST_PER_SESSION_USDis not sufficient protection onopenrouterorcustomroutes — the catalog cannot price them, so the cap never fires. Cap real spend at the provider.
TLS
deploy/Caddyfile is a deployment-agnostic reverse proxy with automatic Let's Encrypt
certificates. Both placeholders come from the environment:
DOMAIN=research.example.com FRONTEND_UPSTREAM=localhost:3031 \
caddy run --config ./deploy/Caddyfile
Or containerised alongside the stack, via deploy/docker-compose.demo.yml.
No domain? sslip.io resolves 203-0-113-7.sslip.io to 203.0.113.7, and Let's Encrypt
will issue a real certificate for it — no registrar, no cost.
Server-sent events need no extra proxy configuration under Caddy, which streams by
default. Under nginx you must disable proxy buffering and gzip for text/event-stream; the
API already sends Cache-Control: no-transform and X-Accel-Buffering: no, which nginx
honours, but a CDN or another layer in front may not.
A worked $0/month deployment
deploy/README.md documents running the whole stack on an Oracle Cloud Always Free VM —
always-on, no expiry, HTTPS included, and no domain required. deploy/oracle-bootstrap.sh
does everything after the three steps that need your own Oracle account: signing up,
creating the instance, and adding the ingress rules.
That deployment runs in LLM_MODE=fake on purpose: a public demo holding no provider key
has nothing for an anonymous visitor to drain, while the UI, the streaming, the approval
gate, and the citation chips are all the production code paths.
Backups
Everything durable — reports, audit rows, memory chunks, and the graph checkpoints — lives in Postgres, so one dump captures full state. Redis holds only queues, caches, rate-limit counters and locks, and is not backed up; a Redis flush must never lose user data.
deploy/backup-postgres.sh does a nightly pg_dump, gzips it, and prunes by retention:
# 2am daily, keeping 14 days
0 2 * * * /opt/research-assistant/deploy/backup-postgres.sh >> /var/log/mara-backup.log 2>&1
Restore:
gunzip -c backup-YYYY-MM-DD.sql.gz | \
docker compose -f docker-compose.full.yml exec -T postgres \
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"
A backup you have never restored is a hypothesis. Test it against a scratch database before you need it.
The per-project corpus SQLite files live outside Postgres, under CORPUS_DIR. Back that
directory up too if users upload documents.
Upgrading
- Pull the new images or rebuild.
- Bring the stack up. The API entrypoint runs
alembic upgrade headbefore serving, and the worker and frontend gate on its readiness — so migrations apply exactly once, before anything else starts. - Check
/health/readyreturns200.
Rolling back one migration is alembic downgrade -1. Rolling back an application version
across a migration that dropped something is not automatic; take a dump first.
Release process
- CI green on
main, including the golden end-to-end journeys. - An evaluation run recorded if prompts or models changed.
- The security checklist reviewed for anything public-facing.
- Tag
vX.Y.Z. The release workflow builds multi-architecture images to GHCR and cuts a GitHub Release; the desktop workflow attaches the desktop bundles and aSHA256SUMS. - Update
frontend/lib/releases.tsand the README download badge — both are hand-written and go stale silently, because nothing fails when they do.
What is not built
Stated so nobody plans around it:
- No Kubernetes manifests, no autoscaling, no sharding. See the scaling path for the order those needs would actually arrive in.
- No metrics dashboard.
GET /metricsserves Prometheus text (operations); building or shipping a dashboard on top of it is yours. The endpoint is unauthenticated and outside/api/v1so it can be withheld at the proxy. - No multi-tenancy beyond per-user isolation. No organisations, roles, or shared workspaces.