--- title: "Helm" description: "Install Obol on Kubernetes with the chart in infra/helm/obol: chart structure, values, the migration lifecycle, and network boundaries." --- Self-hosted deployments are available on the Enterprise plan. See [self-hosting](/get-started/self-hosting). `infra/helm/obol` is the Kubernetes self-host path (ADR-0009). The default posture is bring-your-own Postgres, Redis, and KMS; optional Bitnami subcharts plus a local age key give a self-contained install. The topology mirrors the Compose stack: gateway ×2, control API ×1, control worker ×1, web ×1. There is no connectors workload — see [Trusted workers](/connectors/trusted-workers). The chart deploys **no** connectors workload. `templates/connectors.yaml`, its `connectors` values block and ServiceAccount, and the NetworkPolicy that guarded it were all removed: `apps/connectors/` ships no package and no image, so the template rendered a Deployment that could never pull one, and a NetworkPolicy whose workload did not exist. The chart sets no `OBOL_CONNECTORS_URL`; an operator running a worker of their own supplies it through `gateway.extraEnv`. ## Chart structure ```text infra/helm/obol/ ├── Chart.yaml # obol 0.1.0, appVersion 0.1.0 ├── Chart.lock ├── values.yaml # BYO Postgres/Redis/KMS ├── values-selfhost.yaml # in-cluster Postgres + Redis, local age KEK ├── charts/ # vendored postgresql 15.5.x, redis 20.1.x ├── files/wait-for-database.py └── templates/ ├── gateway.yaml # Deployment, Service, HPA, PDB ├── control.yaml # API Deployment, worker Deployment, migration Job ├── web.yaml ├── bootstrap.yaml # ConfigMap holding wait-for-database.py ├── secret.yaml # rendered only when secrets.values is set ├── ingress.yaml ├── networkpolicy.yaml ├── serviceaccount.yaml ├── _helpers.tpl └── NOTES.txt ``` Both subcharts are conditional: `postgresql.enabled` and `redis.enabled`, both `false` by default. ## Installing Only needed when you enable the in-cluster PostgreSQL or Redis subcharts. ```bash helm dependency update infra/helm/obol ``` The chart expects an existing Secret with these keys: `DATABASE_URL`, `REDIS_URL`, `OBOL_SERVICE_JWT_SECRET`, `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `CLERK_SECRET_KEY`. Stripe and Clerk keys are optional lookups; the service JWT secret is not. ```bash kubectl -n obol create secret generic obol-secrets \ --from-literal=DATABASE_URL='postgresql+psycopg://obol:...@db:5432/obol' \ --from-literal=REDIS_URL='redis://redis:6379/0' \ --from-literal=OBOL_SERVICE_JWT_SECRET="$(openssl rand -hex 32)" ``` Point the chart at it with `secrets.existingSecret`. Use External Secrets or SOPS in production. `secrets.values` renders an inline Secret and is labelled dev convenience only. ```bash helm upgrade --install obol infra/helm/obol -n obol --create-namespace \ --wait --wait-for-jobs --timeout 15m \ --set secrets.existingSecret=obol-secrets \ --set control.clerk.issuer=https://YOUR_INSTANCE.clerk.accounts.dev \ --set urls.web=https://app.example.com \ --set kek.aws.keyId=arn:aws:kms:us-east-1:...:key/... \ --set kek.aws.region=us-east-1 \ --set ingress.hosts.gateway=gateway.example.com ``` Use `--wait --wait-for-jobs` and a Helm timeout longer than `control.migrations.timeoutSeconds`, so a failed bootstrap is reported rather than left running. ```bash kubectl -n obol get pods -l app.kubernetes.io/instance=obol ``` ### Self-contained install `values-selfhost.yaml` enables the in-cluster PostgreSQL and Redis subcharts, switches the KEK to a local age identity, and uses `.obol.local` hosts without TLS. ```bash kubectl -n obol create secret generic obol-age-key --from-file=age.key helm dependency update infra/helm/obol helm upgrade --install obol infra/helm/obol -n obol --create-namespace \ -f infra/helm/obol/values-selfhost.yaml \ --set secrets.values.OBOL_SERVICE_JWT_SECRET="$(openssl rand -hex 32)" ``` Set `kek.local.recipient` to the public age recipient matching that identity, so control can seal credentials it cannot open. ## Values ### URLs and images | Value | Default | Notes | | --- | --- | --- | | `global.imageRegistry` | `ghcr.io/obol` | | | `global.imageTag` | `""` | Falls back to `.Chart.AppVersion` | | `global.env` | `prod` | Becomes `OBOL_ENV` | | `urls.gateway` | `https://gateway.obol.example` | Also the MCP JWT audience | | `urls.control` | `https://control.obol.example` | Also the MCP JWT issuer | | `urls.web` | `https://app.obol.example` | Default for `control.clerk.authorizedParties` | ### Key-encryption key ```yaml kek: provider: aws # aws | gcp | local aws: { keyId: "", region: "" } gcp: { keyName: "" } local: existingSecret: "" # Secret with key `age.key`; mounted only in gateway recipient: "" # public recipient exposed to control, encrypt-only ``` `kek.aws.keyId` is `required` when the provider is `aws`, and `kek.gcp.keyName` when it is `gcp`; rendering fails otherwise. With `provider: local`, the age Secret is mounted at `/run/secrets/age.key` with mode `0400` **in the gateway pod only**. Control receives `OBOL_AGE_RECIPIENT` and nothing more. See [vault and key management](/security/vault). ### Workloads | Value | Default | | --- | --- | | `gateway.replicaCount` | `2` | | `gateway.port` / `gateway.metricsPort` | `8080` / `9091` | | `gateway.idempotencyTtlSeconds` | `86400` | | `gateway.autoscaling.enabled` | `false` (min 2, max 10, 70% CPU) | | `gateway.pdb` | enabled, `minAvailable: 1` | | `gateway.terminationGracePeriodSeconds` | `30` | | `control.api.replicaCount` / `control.worker.replicaCount` | `1` / `1` | | `control.migrations.enabled` | `true` | | `control.migrations.backoffLimit` | `3` | | `control.migrations.timeoutSeconds` | `600` | | `control.clerk.issuer` | `""` — set it, or `/api/v1` answers `503` | | `control.forwardedAllowIps` | `127.0.0.1,::1` | | `web.replicaCount` | `1` | Every workload accepts `extraEnv`, `nodeSelector`, `tolerations`, `affinity`, and `resources`. `web.clerkPublishableKey` is a runtime value and cannot change a compiled client bundle. Build the web image with the matching `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` and public URLs. ### Security context Applied to every pod and container: ```yaml podSecurityContext: runAsNonRoot: true runAsUser: 10001 fsGroup: 10001 seccompProfile: { type: RuntimeDefault } containerSecurityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true capabilities: { drop: [ALL] } ``` Because the root filesystem is read-only, each pod mounts an `emptyDir` at `/tmp`. ### Service accounts ```yaml serviceAccounts: gateway: { create: true, name: "", annotations: {} } control: { create: true, name: "", annotations: {} } web: { create: true, name: "", annotations: {} } ``` Three separate identities, so a gateway KMS decrypt role cannot flow to another workload. Attach KMS IAM (IRSA or Workload Identity) only to gateway and control, and scope each independently — gateway needs decrypt, control needs encrypt. When `create: false`, `name` is required for that component. ## Migration lifecycle The migration is a normal, revision-named Job, not a pre-install hook (ADR-0043). Helm can therefore create the ServiceAccount, Secret, ConfigMap, and optional PostgreSQL subchart before the Job's Pod starts. 1. The Job's init container runs `wait-for-database.py database` — a bounded, read-only connectivity check. 2. The Job container runs `alembic upgrade head`. 3. New control API and worker pods run `wait-for-database.py schema` as their own init container, waiting until the database's complete Alembic head set matches their image's. An empty initial database is a waiting state, not a failure. `control.migrations.timeoutSeconds` (default 600) becomes the Job's `activeDeadlineSeconds` and also bounds each application bootstrap attempt. PostgreSQL connections and queries have five-second timeouts. The Job retries up to `backoffLimit` (default 3), then stays failed for inspection; it is retained as a normal Helm resource with no TTL deletion, so its result survives until the next release revision replaces it. Every Helm revision names a new Job and bumps the API and worker pod-template annotation `obol.dev/migration-revision`, so an upgrade retries a failed bootstrap even with an unchanged image tag. Rolling upgrades keep old application pods while new schema-gated pods wait. Migrations must stay compatible with those old pods; a breaking schema change needs a separate staged migration plan. Inspect a failed Job before retrying — never erase a database to repair a deployment. Setting `control.migrations.enabled: false` also disables the startup gate and makes schema preparation your responsibility. ## Networking ### Ingress `ingress.enabled` is `true` by default, class `nginx`, with three hosts. | Host | Paths routed | | --- | --- | | `ingress.hosts.gateway` | `/` (prefix) | | `ingress.hosts.control` | `/api`, `/oauth`, `/.well-known` (prefix), `/healthz` (exact) | | `ingress.hosts.web` | `/` (prefix) | The control host uses an allowlist of paths precisely so `/internal/*` is never exposed by the public edge. This is the boundary the Fly configuration cannot yet enforce — see [Fly.io](/deploy/fly). TLS is on by default with `ingress.tlsSecretName: obol-tls`. Long LLM streams may need a controller annotation such as `nginx.ingress.kubernetes.io/proxy-read-timeout: "600"`. ### NetworkPolicy `networkPolicy.enabled` is `true` by default and adds one ingress policy: - **gateway** accepts traffic from control and control-worker pods, plus a `namespaceSelector: {}` for the ingress controller and monitoring. That selector is deliberately broad; tighten it to your controller's namespace. ## Observability ```yaml observability: otlpEndpoint: "" serviceMonitor: enabled: false ``` Setting `otlpEndpoint` injects `OTEL_EXPORTER_OTLP_ENDPOINT` into every workload. `serviceMonitor.enabled` creates a prometheus-operator ServiceMonitor for the gateway's `/metrics` on `gateway.metricsPort`. See [telemetry](/observability/telemetry). ## Validating without a cluster ```bash make -C infra helm-lint ``` The target adds the Bitnami repository, builds chart dependencies, then lints and renders both the default and the self-host profile. It supplies a synthetic KMS key ARN so the `required` guard on `kek.aws.keyId` does not block rendering. It starts nothing and contacts no cluster. ## Related The environment contract shared by all three deploy targets. Why the gateway has no database and state reaches it only as snapshots.