Docker Compose is the fastest way to run a Sannvit Ledger instance, whether that's a local dev environment or a single-host production deployment. Everything below assumes you've already cloned the repo.
The stack is split into one base file plus overrides you layer on as needed:
| Service | Compose file | What it is |
|---|---|---|
db | docker-compose.yml | Plain postgres:17 — the only database in the stack |
keycloak | docker-compose.keycloak.yml | Auth — Direct Access Grant, no hosted login UI |
api / alerting | docker-compose.api.yml | The ingest service, plus its standalone integrity/alerting runner as a second container |
dashboard | docker-compose.dashboard.yml | The browser UI |
rustfs | docker-compose.rustfs.yml | Bundled S3-compatible blob store — optional, see Bring your own S3-compatible storage |
mail | docker-compose.mail.yml | Dev-only SMTP catcher (Mailpit) so invite/reset/alert emails land somewhere visible |
nginx / caddy | docker-compose.nginx.yml / docker-compose.caddy.yml | Optional TLS-terminating reverse proxy in front of app. / api. / auth. subdomains |
The app talks to plain Postgres directly and Keycloak handles auth — no extra services in between — which keeps the footprint small enough to install on a client's own infrastructure.
.env lives at the repo root and is read by every compose file.
cp .env.example .env
At minimum, fill in these secrets before starting anything — openssl rand -hex 24 for each:
POSTGRES_PASSWORDJWT_SECRETKEYCLOAK_ADMIN_PASSWORDKEYCLOAK_SERVICE_CLIENT_SECRETS3_ROOT_PASSWORD (only needed if you're using the bundled blob store)EXPORT_SECRET_KEY (only needed before using the evidence-pack SFTP export feature)Dev-only values are fine for local work — just don't reuse them anywhere real.
docker/run.sh manages which compose override files are active and always reads .env from the repo root, regardless of your shell's working directory:
cd docker
./run.sh config add keycloak rustfs mail api api.dev dashboard dashboard.dev
./run.sh start
api.dev / dashboard.dev layer in hot-reload (bind-mounted source, tsx watch / Nuxt HMR) on top of their base services.
npm run prod
which is equivalent to:
cd docker
COMPOSE_FILE=docker-compose.yml:docker-compose.keycloak.yml:docker-compose.rustfs.yml:docker-compose.api.yml:docker-compose.dashboard.yml \
COMPOSE_ENV_FILES=../.env COMPOSE_IGNORE_ORPHANS=true \
docker compose up -d --wait --build
!WARNING Before doing this for real:
- The values in
.env.exampleare placeholders, not secrets — set real, unique values for every entry listed above.- Put a real TLS-terminating reverse proxy in front (
docker-compose.nginx.ymlor.caddy.yml) rather than exposing raw service ports, and setPROXY_DOMAINin.env.- Change Keycloak's
ledger-serviceclient secret from the realm export's dev default (docker/volumes/keycloak/ledger-realm.json) via the Keycloak admin console, and updateKEYCLOAK_SERVICE_CLIENT_SECRETin.envto match.- Point
SMTP_*at a real relay instead of Mailpit, and setDASHBOARD_URLto the real public dashboard domain — see Email (SMTP) below.- Set up proper backup procedures for the
dbvolume.
This is a one-time step per fresh database — not part of run.sh start. Wait for db and keycloak to report healthy (./run.sh status), then from the repo root:
for f in api/drizzle/0*.sql; do
psql -h localhost -U postgres -d postgres -v ON_ERROR_STOP=1 -f "$f"
done
cd api
npm install
npm run db:functions # applies src/db/functions/*.sql — not drizzle-kit objects
npm run seed:demo # demo org + team + ingest key (key printed once, save it)
There's no seeded dashboard user — seed:demo only creates a demo org/team/ingest key for the SDK side. To get a human login, bootstrap the first admin instead (from api/, once db and keycloak are up and migrations are applied):
npm run seed:admin -- you@example.com --org-name="Your Org Name"
This prints a generated password once. Every subsequent user gets invited through the dashboard (Organization → Users → Invite) by an existing compliance_admin, not through this script.
http://localhost:3000http://localhost:4010 (GET /healthz)http://localhost:8180http://localhost:8025http://localhost:9001 (bundled RustFS, if you kept it)run.sh commands./run.sh logs [service] # follow logs
./run.sh status # docker compose ps
./run.sh recreate [service] # force-recreate one service
./run.sh stop # stop everything
Calling docker compose directly instead of run.sh needs the env file spelled out explicitly, e.g. from the repo root:
docker compose --env-file .env -f docker/docker-compose.yml -f docker/docker-compose.keycloak.yml up -d
By default the stack bundles a RustFS container as the blob store for payload content — the ledger itself only stores a pointer + hash. If you'd rather point at an existing bucket (AWS S3, Cloudflare R2, Backblaze B2, your own MinIO/RustFS, etc.):
rustfs override — skip ./run.sh config add rustfs, or ./run.sh config remove rustfs if it's already there..env, set S3_ENDPOINT, S3_PORT, S3_USE_SSL, and optionally S3_REGION to the external provider, and S3_ACCESS_KEY / S3_SECRET_KEY to credentials scoped to that bucket. Set LEDGER_BUCKET to the bucket name.CreateBucket permission.The append-only second integrity plane is a second bucket (LEDGER_INTEGRITY_BUCKET, default ledger-integrity) on the same endpoint, created with S3 Object Lock enabled — undeletable for its retention window, even by admins.
!CAUTION The bundled RustFS container has open bugs in its Object Lock implementation. That's fine for dev, but production deployments should point
LEDGER_INTEGRITY_BUCKETat a mature Object Lock implementation via the bring-your-own-S3 path — real AWS S3, Backblaze B2, or GCS via S3 interop.
The ledger API sends invite, password-reset, and alert-digest emails via SMTP.
Local dev — capture emails instead of sending them:
./run.sh config add mail
./run.sh start
Every email lands at http://localhost:8025 (Mailpit) instead of a real inbox.
Production — deliver real email:
SMTP_ADMIN_EMAIL, SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, and SMTP_SENDER_NAME in that environment's .env. Leave SMTP_ALLOW_INSECURE_AUTH unset — it exists only for Mailpit.DASHBOARD_URL to the real public dashboard domain — it gets embedded in invite/reset email links.mail override in production, or it'll intercept real mail../run.sh recreate api alerting.If your provider requires domain verification (SPF/DKIM, sender identity — common with SES and Postmark), that has to be completed on the provider's side before mail will actually deliver.
docker-compose.*.yml if needed.docker compose pull.api / dashboard if their source changed: docker compose -f docker-compose.api.yml -f docker-compose.dashboard.yml up -d --build.Always back up your database before updating.
For a cluster deployment beyond one Compose host, there's a Helm-based Kubernetes path — building/pushing images, secrets, DNS, and helm install — covered separately from Docker Compose.