Production with docker-compose
docker-compose.prod.yml is the production-shaped compose file. It
pulls pre-built multi-arch images from Docker Hub
(scani/api, scani/worker, scani/data-provider,
scani/frontend-app) and wires them up with Postgres, Redis, and
MinIO. Run it as-is for a one-box deploy, or comment out the
data-plane services and point at managed Postgres / Redis / S3
endpoints (see Managed services).
The one-command bring-up
Section titled “The one-command bring-up”-
Get the file.
Terminal window curl -O https://raw.githubusercontent.com/MGrin/scani-oss/main/docker-compose.prod.ymlcurl -O https://raw.githubusercontent.com/MGrin/scani-oss/main/.env.examplemv .env.example .env -
Fill in the required values in
.env.See Required environment variables — at minimum:
BACKEND_URL,FRONTEND_URL,BETTER_AUTH_SECRET,ENCRYPTION_KEY,JOBS_HMAC_SECRET,DATA_PROVIDER_API_KEY,SCANI_CLOUD_API_KEY,LOG_ID_PEPPER. -
Apply database migrations. Do this on every fresh install AND on every upgrade, before bringing the long-running services up.
Terminal window docker compose -f docker-compose.prod.yml --profile migrate run --rm migrateThe
migrateservice is profile-gated so it doesn’t run on everyup -d— see Apply migrations below for why and for the alternative orchestrators (Kubernetes Job, CI step,docker runstandalone). -
Boot the long-running services.
Terminal window docker compose -f docker-compose.prod.yml up -ddocker compose -f docker-compose.prod.yml logs -f frontend-app api worker -
Put a TLS-terminating reverse proxy in front of
frontend-app:8080.See TLS & reverse proxy for Caddy and nginx examples.
What’s in the compose file
Section titled “What’s in the compose file”| Service | Image | Port | Reachable from |
|---|---|---|---|
postgres | postgres:16-alpine | (internal) | Other compose services. |
redis | redis:7-alpine | (internal) | Other compose services. |
minio + minio-init | minio/minio:latest, minio/mc:latest | (internal) | Other compose services. |
migrate (profile-gated) | scani/migrate:${SCANI_IMAGE_TAG:-latest} | (none) | One-shot; exits after applying migrations. |
data-provider | scani/data-provider:${SCANI_IMAGE_TAG:-latest} | (internal) | api, worker. |
api | scani/api:${SCANI_IMAGE_TAG:-latest} | (internal) | frontend-app via nginx reverse proxy. |
worker | scani/worker:${SCANI_IMAGE_TAG:-latest} | (none) | n/a — consumes from Redis. |
frontend-app | scani/frontend-app:${SCANI_IMAGE_TAG:-latest} | host ${FRONTEND_PORT:-8080} | Public internet (behind your TLS proxy). |
Only frontend-app needs to be reachable from the public internet.
nginx inside the frontend-app container reverse-proxies /api and
/ws to api:3001 over the compose network — no extra wiring on
your side.
Boot order
Section titled “Boot order”Healthchecks gate startup so services come up in the right order:
postgres ─┐redis ────┤ ┌── api ──┐ ├─→ data-provider ─┼─→ frontend-appminio ────┘ └─ worker ┘data-providerwaits forpostgres:healthyandredis:healthy.apiandworkerwait fordata-provider:healthy(so they can reach it on first call).frontend-appwaits forapi:healthy.
The migrate service is not in this boot graph — it’s
profile-gated and you run it explicitly. See the next section.
Apply migrations
Section titled “Apply migrations”Schema changes ship as Drizzle migration files
(packages/infra/db/src/migrations/*.sql). They’re packaged into a
small standalone image (scani/migrate) and applied explicitly,
not baked into the api/worker/data-provider runtime images.
The reasoning: silently auto-migrating on app boot creates two failure modes operators can’t see — race conditions between replicas, and partial-apply states when a migration crashes mid-way. Making it explicit means schema changes are a step you (or your deploy pipeline) control.
Three ways to run it, pick whichever fits your infrastructure:
1. Via docker-compose (recommended for compose-based deploys)
Section titled “1. Via docker-compose (recommended for compose-based deploys)”docker compose -f docker-compose.prod.yml --profile migrate run --rm migrateThe --rm flag removes the one-shot container as soon as it exits.
Re-running is a safe no-op for already-applied migrations.
2. Standalone docker run (Kubernetes Job / CI step / one-off)
Section titled “2. Standalone docker run (Kubernetes Job / CI step / one-off)”docker run --rm \ -e DATABASE_URL="$DATABASE_URL" \ scani/migrate:${SCANI_IMAGE_TAG:-latest}Use this from a Kubernetes Job, an external CI deploy step, or any
orchestrator that isn’t docker-compose. Pin the same tag you’re
using for the other scani/* images — mixing versions is unsupported.
3. From a git checkout (development / debugging)
Section titled “3. From a git checkout (development / debugging)”git clone https://github.com/MGrin/scani-oss.git && cd scani-ossbun installDATABASE_URL="$DATABASE_URL" bun run db:migrateThe same migrate.ts script the Docker image wraps. Useful when you
want to inspect a migration before applying it, or when you’re
iterating on a new migration locally.
When to run it
Section titled “When to run it”- First install: before the first
docker compose -f docker-compose.prod.yml up -d. - Every upgrade: after
docker compose pulland beforedocker compose up -d/restart. Some upgrades ship schema changes; some don’t. Always running migrate first is safe and cheap.
How the app behaves if you forget
Section titled “How the app behaves if you forget”api’sGET /readyzreturns 503 with aSchema not ready: missing tables <list>payload.workerlogs⏳ Awaiting schema readiness before scheduler registration, times out after 60s, restart-loops.frontend-app’sGET /healthzstill returns 200 (it’s just nginx), so don’t trust it as a “stack is up” signal./api/readyzis the authoritative one.
Image tags
Section titled “Image tags”| Tag | Pushed on |
|---|---|
:1.2.3, :1.2, :1 | Each v* release tag (cut by release-please). |
:latest | The most recent v* release (re-tagged on every release). |
Releases are gated on v* tags so that what reaches consumers is
always a deliberate, reviewed cut. main pushes are intentionally
not a publish trigger — bleeding-edge builds against main are not
pushed to Docker Hub. If you need them, clone the repo and build the
images locally.
Pin SCANI_IMAGE_TAG=1.2.3 in .env for reproducible deploys.
:latest is convenient for one-command bring-up but moves under you
whenever a new release lands; pinning a specific version protects
production from accidental upgrades.
See Upgrades & version pinning for the safer upgrade flow.
Multi-arch
Section titled “Multi-arch”Images are built for linux/amd64 and linux/arm64. Docker selects
the right variant for your host automatically. Apple Silicon and
Graviton machines work out of the box.
Public-internet shape
Section titled “Public-internet shape”Internet ─→ Your reverse proxy (TLS) ─→ frontend-app:8080 │ │ /api, /ws ▼ api:3001 ─→ data-provider:8082 │ │ ▼ ▼ postgres:5432 postgres:5432 redis:6379 redis:6379 │ ▼ worker- Public TLS lives on your reverse proxy.
api,worker,data-provider,postgres,redis,minioall live on the compose internal network. None of them are exposed to the host (ports:are omitted indocker-compose.prod.yml).
Required-in-production env shape
Section titled “Required-in-production env shape”The compose file uses ${VAR:?message} for must-set vars — booting
without them fails fast with a readable message:
FRONTEND_URL: ${FRONTEND_URL:?FRONTEND_URL is required (public URL of the SPA, used for CORS + cookie scope)}BACKEND_URL: ${BACKEND_URL:?BACKEND_URL is required (public URL of the API, embedded in magic-link emails)}BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:?BETTER_AUTH_SECRET is required}ENCRYPTION_KEY: ${ENCRYPTION_KEY:?ENCRYPTION_KEY is required (must match the worker)}JOBS_HMAC_SECRET: ${JOBS_HMAC_SECRET:?JOBS_HMAC_SECRET is required}DATA_PROVIDER_API_KEY: ${DATA_PROVIDER_API_KEY:?DATA_PROVIDER_API_KEY is required}SCANI_CLOUD_API_KEY: ${SCANI_CLOUD_API_KEY:?SCANI_CLOUD_API_KEY is required (must match DATA_PROVIDER_API_KEY)}LOG_ID_PEPPER: ${LOG_ID_PEPPER:?LOG_ID_PEPPER is required in production}SCANI_CLOUD_API_KEY and DATA_PROVIDER_API_KEY must match in a
single-tenant Tier-1 deployment (api/worker present one bearer; the
data-provider validates against the same value).