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, MinIO and a mail catcher. 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).

From any empty directory:

Terminal window
curl -fsSL https://raw.githubusercontent.com/MGrin/scani-oss/main/scripts/self-host.sh | bash

That is the whole install. scripts/self-host.sh fetches this compose file, generates a .env full of real secrets, pulls the images, applies migrations and brings the stack up, then waits for it to answer HTTP before saying so. Scani is on http://localhost:8080; your sign-in code is at http://localhost:8026.

Knobs, all optional: SCANI_IMAGE_TAG (pin a release), SCANI_PORT, SCANI_MAIL_PORT, SCANI_REF, and SCANI_SKIP_UP=1 to write .env and stop before anything boots. SCANI_RESET=1 deletes this project’s data volumes first — see Troubleshooting.

Re-running the script is the recovery from a failed install: it reuses the .env it finds rather than generating new secrets, so a second run converges on the first one’s. What it will not do is generate fresh secrets over a database that already exists — if the volumes are there and the .env is not, it stops and says so instead of leaving you with password authentication failed for user "scani".

If you would rather run each step yourself, or you are wiring this into another orchestrator:

  1. Get the file.

    Terminal window
    curl -O https://raw.githubusercontent.com/MGrin/scani-oss/main/docker-compose.prod.yml
  2. Write a .env beside it. Every value below must be a real secret, not a placeholder — generate each with openssl rand -hex 32: BETTER_AUTH_SECRET, ENCRYPTION_KEY, JOBS_HMAC_SECRET, LOG_ID_PEPPER, and DATA_PROVIDER_API_KEY / SCANI_CLOUD_API_KEY (those last two are two ends of one bearer and must be equal).

    Then FRONTEND_URL and BACKEND_URL, both the public origin of your instance. BACKEND_URL must carry no path even though the API is reached under /api — Better-Auth derives its route base path from it, and a path there silently moves every auth route out of reach.

  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.
mailpitaxllent/mailpit:latest127.0.0.1:8026You, to read your own sign-in code. Replace with real SMTP before anyone else signs in.
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 the queue from Postgres.
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).