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.
Every component is templated directly — no external chart dependency:
| Component | What it is |
|---|---|
postgres | Plain Postgres 17, single-instance StatefulSet |
keycloak | Auth — Direct Access Grant, no hosted login UI |
rustfs | Blob store for payload content, and the WORM-locked bucket backing the append-only integrity plane — optional, see Bring your own S3-compatible storage |
ledger-api | The ingest service |
ledger-alerting | Standalone chain re-verification/mirror/anchor/outbox runner — same image as ledger-api, different command, deliberately a singleton |
dashboard | The browser UI |
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+ locallyingress.className: nginx in values.yaml); change it there and in your secrets file if the client runs something elseClusterIssuer already created — its name goes in ingress.annotations["cert-manager.io/cluster-issuer"]StorageClass (or explicit storageClassName values) — Postgres and the bundled blob store claim persistent volumesmetrics-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 scaleThe 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>
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:
ingress.dashboardHost, ingress.apiHost, ingress.authHost.ledgerApi.image.repository/tag, ledgerAlerting.image.repository/tag (same image as ledgerApi), dashboard.image.repository/tag.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.
helm install <release> . -n <namespace> --create-namespace \
-f values.yaml -f values-secrets.<client>.yaml
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.
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.
curl -s https://<apiHost>/healthz
and open https://<dashboardHost> in a browser.
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.
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.
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.
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).postgres Service — add a pooler (e.g. PgBouncer) as a separate Deployment in front of it if pooling becomes necessary under load.docker build/push today, as in step 1 — no CI automation for it yet.