SVSANNVIT
Getting Started

Hosting on Kubernetes

Deploy the Sannvit Ledger to a client's Kubernetes cluster with the Helm chart, for horizontal scaling beyond a single host.

A second deployment path alongside Docker Compose — for a cluster that already exists and needs a Helm release, or that specifically needs to scale the stateless services horizontally (e.g. behind an HPA) rather than running everything on one host.

Use Docker Compose for a single-host self-hosted deployment — it's simpler to operate, and is still the reference implementation for how every service is wired together.

Use this chart once a client's cluster already exists, or scaling needs outgrow what one Compose host can do.

What the chart deploys

Every component is templated directly — no external chart dependency:

ComponentWhat it is
postgresPlain Postgres 17, single-instance StatefulSet
keycloakAuth — Direct Access Grant, no hosted login UI
rustfsBlob store for payload content, and the WORM-locked bucket backing the append-only integrity plane — optional, see Bring your own S3-compatible storage
ledger-apiThe ingest service
ledger-alertingStandalone chain re-verification/mirror/anchor/outbox runner — same image as ledger-api, different command, deliberately a singleton
dashboardThe browser UI

Prerequisites

Already expected to exist in the client's cluster — not this chart's job to provide:

  • kubectl configured for the target cluster, and Helm 3.8+ locally
  • An ingress controller — nginx-ingress by default (ingress.className: nginx in values.yaml); change it there and in your secrets file if the client runs something else
  • cert-manager installed, with a ClusterIssuer already created — its name goes in ingress.annotations["cert-manager.io/cluster-issuer"]
  • A default StorageClass (or explicit storageClassName values) — Postgres and the bundled blob store claim persistent volumes
  • metrics-server, if you're keeping autoscaling enabled — the defaults turn on HPAs for ledger-api/dashboard, and without it those HPAs show <unknown> for CPU and never scale
  • DNS control for the dashboard/API/auth hostnames — records don't need to be live yet, just creatable

1. Build and push the images

The chart expects ledgerApi.image.repository / dashboard.image.repository to already exist in a registry the cluster can pull from — it doesn't build or publish them.

docker build -t <registry>/<client>/ledger-api:<tag> -f api/Dockerfile api
docker push <registry>/<client>/ledger-api:<tag>

docker build -t <registry>/<client>/ledger-dashboard:<tag> -f dashboard/Dockerfile dashboard
docker push <registry>/<client>/ledger-dashboard:<tag>

2. Prepare the client's secrets and config

cd k8s/ledger
cp values-example-secrets.yaml values-secrets.<client>.yaml

Fill in values-secrets.<client>.yaml — it's gitignored, one file per client, never committed:

  • Set the hostnames: ingress.dashboardHost, ingress.apiHost, ingress.authHost.
  • Set the image references from step 1: ledgerApi.image.repository/tag, ledgerAlerting.image.repository/tag (same image as ledgerApi), dashboard.image.repository/tag.
  • If the client wants to bring their own Postgres instead of the in-chart one, see Known limitations before proceeding.

3. Point DNS at the ingress controller

kubectl get svc -n <ingress-namespace> <ingress-controller-service>

Create A/CNAME records for all three hostnames from step 2 pointing at that address. This can happen before install — cert-manager's HTTP-01 challenges just won't succeed (and TLS certs won't issue) until the records resolve, so do it as early as convenient.

4. Install

helm install <release> . -n <namespace> --create-namespace \
  -f values.yaml -f values-secrets.<client>.yaml

5. Watch the rollout

kubectl -n <namespace> get pods -w

Postgres and Keycloak take the longest on first boot. If something sticks in CrashLoopBackOff or Pending, kubectl -n <namespace> logs <pod> and kubectl -n <namespace> describe pod <pod> are the first two commands to reach for — a Pending PVC almost always means no default StorageClass.

6. Apply migrations

The chart brings up an empty Postgres — schema migrations are a separate step, same as the Compose path:

kubectl -n <namespace> port-forward svc/postgres 5432:5432 &

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 && DATABASE_URL=postgresql://postgres:<PGPASSWORD>@localhost:5432/postgres npm run db:functions

PGPASSWORD is secrets.postgresPassword from your values-secrets file. Apply the api/drizzle/*.sql files in numeric order — 0000_baseline.sql creates the full schema from scratch, no separate bootstrap step needed. npm run db:functions then applies every function/view/trigger that isn't a drizzle-kit object.

7. Verify

curl -s https://<apiHost>/healthz

and open https://<dashboardHost> in a browser.

8. First login

There's no seeded admin user. Create one with the same script the Compose path uses:

kubectl -n <namespace> exec deploy/ledger-api -- node dist/seed_admin.js \
  you@example.com --org-name="Your Org Name"

Omit --org-name and pass --org=<id> instead if the org already exists. Prints a generated password once — log in at https://<dashboardHost> with it, then invite everyone else through the dashboard.

Upgrading

The ledger app (new ledger-api/dashboard image): repeat step 1, bump ledgerApi.image.tag/dashboard.image.tag in the client's values file, then:

helm upgrade <release> . -n <namespace> -f values.yaml -f values-secrets.<client>.yaml

Schema changes: apply any new api/drizzle/*.sql files the same way as step 6, before upgrading ledger-api/ledger-alerting to a version that expects them.

Bring your own S3-compatible storage

By default this chart runs a bundled blob-store StatefulSet. If a client already has their own S3-compatible bucket (AWS S3, Cloudflare R2, Backblaze B2, their own MinIO/RustFS, etc.) and doesn't want the bundled StatefulSet and its PVC, set in the client's values file:

rustfs:
  enabled: false

storage:
  endpoint: "s3.us-east-1.amazonaws.com"
  port: 443
  useSsl: true
  region: "us-east-1"

secrets:
  s3RootUser: "<access key scoped to the client's bucket>"
  s3RootPassword: "<secret key>"

ledgerApi.storage.bucket still names the bucket to use — either pre-create it or grant the given credentials CreateBucket permission; the ledger API creates it lazily on first upload if missing.

Known limitations

  • Postgres HA. Single-instance StatefulSet — this chart scales the stateless API layer, not the database. For real HA, point at an external managed Postgres instead (postgres.enabled: false, then point ledger-api/ledger-alerting/keycloak's database URLs at it via values overrides) or a Postgres operator (CloudNativePG, StackGres).
  • Connection pooling. Services connect directly to the postgres Service — add a pooler (e.g. PgBouncer) as a separate Deployment in front of it if pooling becomes necessary under load.
  • Keycloak clustering. Runs as a single replica — scaling beyond one needs Keycloak's own clustering config wired up separately.
  • Image publishing. Manual docker build/push today, as in step 1 — no CI automation for it yet.