Skip to content

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).

  1. Get the file.

    Terminal window
    curl -O https://raw.githubusercontent.com/MGrin/scani-oss/main/docker-compose.prod.yml
    curl -O https://raw.githubusercontent.com/MGrin/scani-oss/main/.env.example
    mv .env.example .env
  2. 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.

  3. 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 migrate

    The migrate service is profile-gated so it doesn’t run on every up -d — see Apply migrations below for why and for the alternative orchestrators (Kubernetes Job, CI step, docker run standalone).

  4. Boot the long-running services.

    Terminal window
    docker compose -f docker-compose.prod.yml up -d
    docker compose -f docker-compose.prod.yml logs -f frontend-app api worker
  5. Put a TLS-terminating reverse proxy in front of frontend-app:8080.

    See TLS & reverse proxy for Caddy and nginx examples.

ServiceImagePortReachable from
postgrespostgres:16-alpine(internal)Other compose services.
redisredis:7-alpine(internal)Other compose services.
minio + minio-initminio/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-providerscani/data-provider:${SCANI_IMAGE_TAG:-latest}(internal)api, worker.
apiscani/api:${SCANI_IMAGE_TAG:-latest}(internal)frontend-app via nginx reverse proxy.
workerscani/worker:${SCANI_IMAGE_TAG:-latest}(none)n/a — consumes from Redis.
frontend-appscani/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.

Healthchecks gate startup so services come up in the right order:

postgres ─┐
redis ────┤ ┌── api ──┐
├─→ data-provider ─┼─→ frontend-app
minio ────┘ └─ worker ┘
  • data-provider waits for postgres:healthy and redis:healthy.
  • api and worker wait for data-provider:healthy (so they can reach it on first call).
  • frontend-app waits for api:healthy.

The migrate service is not in this boot graph — it’s profile-gated and you run it explicitly. See the next section.

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:

Section titled “1. Via docker-compose (recommended for compose-based deploys)”
Terminal window
docker compose -f docker-compose.prod.yml --profile migrate run --rm migrate

The --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)”
Terminal window
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)”
Terminal window
git clone https://github.com/MGrin/scani-oss.git && cd scani-oss
bun install
DATABASE_URL="$DATABASE_URL" bun run db:migrate

The 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.

  • First install: before the first docker compose -f docker-compose.prod.yml up -d.
  • Every upgrade: after docker compose pull and before docker compose up -d / restart. Some upgrades ship schema changes; some don’t. Always running migrate first is safe and cheap.
  • api’s GET /readyz returns 503 with a Schema not ready: missing tables <list> payload.
  • worker logs ⏳ Awaiting schema readiness before scheduler registration, times out after 60s, restart-loops.
  • frontend-app’s GET /healthz still returns 200 (it’s just nginx), so don’t trust it as a “stack is up” signal. /api/readyz is the authoritative one.
TagPushed on
:1.2.3, :1.2, :1Each v* release tag (cut by release-please).
:latestThe 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.

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.

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, minio all live on the compose internal network. None of them are exposed to the host (ports: are omitted in docker-compose.prod.yml).

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).