Self-hosted deployments are available on the Enterprise plan. See
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.
Chart structure
postgresql.enabled and redis.enabled, both
false by default.
Installing
1
Build chart dependencies
Only needed when you enable the in-cluster PostgreSQL or Redis subcharts.
2
Create the application secret
The chart expects an existing Secret with these keys: Point the chart at it with
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.secrets.existingSecret. Use External Secrets or
SOPS in production. secrets.values renders an inline Secret and is labelled dev
convenience only.3
Install
--wait --wait-for-jobs and a Helm timeout longer than
control.migrations.timeoutSeconds, so a failed bootstrap is reported rather
than left running.4
Check readiness
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.
kek.local.recipient to the public age recipient matching that identity, so
control can seal credentials it cannot open.
Values
URLs and images
Key-encryption key
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.
Workloads
Every workload accepts
extraEnv, nodeSelector, tolerations, affinity, and
resources.
Security context
Applied to every pod and container:emptyDir at
/tmp.
Service accounts
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.- The Job’s init container runs
wait-for-database.py database— a bounded, read-only connectivity check. - The Job container runs
alembic upgrade head. - New control API and worker pods run
wait-for-database.py schemaas 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.
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.
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.
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
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.
Validating without a cluster
required guard on kek.aws.keyId does not block rendering. It
starts nothing and contacts no cluster.
Related
Self-hosting
The environment contract shared by all three deploy targets.
Invariants
Why the gateway has no database and state reaches it only as snapshots.