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).
The one-command bring-up
Section titled “The one-command bring-up”From any empty directory:
curl -fsSL https://raw.githubusercontent.com/MGrin/scani-oss/main/scripts/self-host.sh | bashThat 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".
Doing it by hand
Section titled “Doing it by hand”If you would rather run each step yourself, or you are wiring this into another orchestrator:
-
Get the file.
Terminal window curl -O https://raw.githubusercontent.com/MGrin/scani-oss/main/docker-compose.prod.yml -
Write a
.envbeside it. Every value below must be a real secret, not a placeholder — generate each withopenssl rand -hex 32:BETTER_AUTH_SECRET,ENCRYPTION_KEY,JOBS_HMAC_SECRET,LOG_ID_PEPPER, andDATA_PROVIDER_API_KEY/SCANI_CLOUD_API_KEY(those last two are two ends of one bearer and must be equal).Then
FRONTEND_URLandBACKEND_URL, both the public origin of your instance.BACKEND_URLmust 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. -
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”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”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).