Install with Helm
Kubernetes is the supported production deployment path.
Before you start
| Prerequisite | Notes |
|---|---|
| PostgreSQL 18 | External. A CloudNativePG example is in the chart: examples/cnpg-cluster.yaml |
| Valkey or Redis | External. A minimal StatefulSet example is in the chart: examples/valkey-statefulset.yaml |
| Ingress controller | Any. The chart emits a standard Ingress; set ingress.className (default nginx) |
| cert-manager, or a TLS secret | For ingress.tls |
| StorageClass | For the dev_file key volume and local image storage, if used. ReadWriteMany when replicas span nodes |
| SMTP relay | Required |
Download and verify the release first: Download and verify. The chart has no Bitnami dependency — those images moved to docker.io/bitnamilegacy in August 2025 and no longer receive security patches.
1. Pull access
The chart version is the release version without its leading v: release v0.34.28 is chart 0.34.28. Each chart pins that release's images by digest.
NS=lutraid
SLUG=<your-slug>
VERSION=0.34.28
kubectl create namespace $NS
helm registry login registry.lutralabs.io
kubectl -n $NS create secret docker-registry lutraid-registry \
--docker-server=registry.lutralabs.io \
--docker-username=<username> --docker-password=<password>
2. Secrets
Nothing sensitive goes in values. Every secret is referenced by name and mounted as a file, so values never appear in a pod spec and rotation is a restart rather than a manifest edit. External Secrets Operator, OpenBao and Vault integrate by creating these Secrets — the chart has no coupling to any of them.
kubectl -n $NS create secret generic lutraid-db --from-literal=uri="postgresql://user:pass@host:5432/lutraid"
kubectl -n $NS create secret generic lutraid-auth --from-literal=secret="$(openssl rand -base64 48)"
# The first administrator's password — at least 8 characters. Delete it after install.
kubectl -n $NS create secret generic lutraid-admin --from-literal=password='<password>'
If your relay needs authentication, also create a Secret with its password and set email.smtp.username and email.smtp.existingSecret.
3. Install
helm install lutraid oci://registry.lutralabs.io/customers/$SLUG/lutralabs/charts/lutraid \
--version $VERSION -n $NS \
--set global.imageRegistry=registry.lutralabs.io/customers/$SLUG \
--set 'global.imagePullSecrets[0].name=lutraid-registry' \
--set host=id.example.gov.si \
--set database.existingSecret=lutraid-db \
--set auth.existingSecret=lutraid-auth \
--set valkey.host=valkey.lutraid.svc.cluster.local \
--set email.smtp.host=smtp.example.gov.si \
--set email.from='LutraID <noreply@id.example.gov.si>' \
--set bootstrap.superadmin.enabled=true \
--set bootstrap.superadmin.email=you@example.gov.si \
--set bootstrap.superadmin.existingSecret=lutraid-admin
host derives PUBLIC_BASE_URL and FRONTEND_URL. It becomes the issuer identifier embedded in every credential you issue — decide it before you go live.
Schema migrations run as a pre-install hook before any application pod starts. helm install fails fast with a message naming a missing value, rather than deploying a broken release.
The first administrator is created by a post-install hook: it runs once, at install, and never on upgrades. That is why it is part of the install command above — setting it later with helm upgrade does nothing. Set bootstrap.superadmin.enabled=false on your next upgrade to drop it from your values.
4. First administrator and installation ID
kubectl -n $NS logs job/lutraid-bootstrap-superadmin
kubectl -n $NS delete secret lutraid-admin
The Job log ends with Installation ID: <uuid>. Keep it — a production license is bound to it. Sign in at https://<host>; the account is pre-verified.
5. Install the license
kubectl -n $NS create secret generic lutraid-license --from-file=license=license.lic
helm upgrade lutraid oci://registry.lutralabs.io/customers/$SLUG/lutralabs/charts/lutraid \
--version $VERSION -n $NS --reuse-values --set license.existingSecret=lutraid-license
No license yet? LutraID runs normally for 30 days from first start. You can also install it through the API with no restart: see License.
Values worth knowing
values.yaml in the chart has the full set, and values.schema.json validates it at install time.
| Key | Purpose |
|---|---|
global.imageRegistry | The single registry value. The one thing to change when mirroring |
global.imageMap | For registries that flatten paths — Harbor projects, ECR one-repo-per-image |
ingress.className, ingress.tls.secretName | Your ingress controller and TLS secret |
keys.backend | dev_file (default) or aws_kms. See Signing keys |
imageStorage.driver | local (a PVC) or s3. S3 works against MinIO and Ceph through an endpoint override |
email.transport | smtp (default) or resend |
<workload>.replicaCount, .resources, .nodeSelector, .affinity, .tolerations | Per workload: backend, frontend, worker, outboxRelay, statusPublisher |
maintenance.tasks.* | Five retention CronJobs. Do not disable them — they are how expired data is actually deleted |
Verify the install
helm test lutraid -n $NS
kubectl -n $NS get pods -l app.kubernetes.io/instance=lutraid
helm test runs a health probe. If the install failed, the migration Job is deliberately left in place so its logs survive.
Every release is installed from its Compose bundle, brought up and exercised before it reaches your namespace. The chart is linted, rendered and digest-checked at release, but not yet installed end to end on a live cluster by the release pipeline. Tell us if anything in this path does not match what you see.