--- title: "Self-hosting" description: "Run the full Obol stack in your own environment. An Enterprise-plan feature: the services, the required secrets, and what an upgrade involves." --- Self-hosting the full Obol stack is an Enterprise-plan feature, offered at a premium. The hosted platform is the default way to use Obol — see the [quickstart](/get-started/quickstart). Contact the Obol team to have self-hosting enabled for your organization. The rest of this page is the operational reference for enterprise customers running Obol themselves. Self-hosting Obol is `docker compose up` against `infra/docker-compose.yml`. That is not a stripped-down variant of the development environment — it is the same file with the same images. Development layers a hot-reload overlay on top of it; nothing is added for production (ADR-0009). Three deploy targets exist, and all three enforce the same topology: | Target | Path | For | | --- | --- | --- | | Docker Compose | `infra/docker-compose.yml` | local development and single-host self-host | | Fly.io | `infra/fly/*.fly.toml` | hosted v1, one region | | Helm | `infra/helm/obol/` | self-host on Kubernetes | The deploy references — [Docker Compose](/deploy/docker-compose), [Fly.io](/deploy/fly), and [Helm](/deploy/helm) — describe Enterprise self-host deployments. They do not apply to the hosted platform, which Obol operates for you. ## What runs ```bash docker compose -f infra/docker-compose.yml --env-file infra/.env up --build -d ``` or, equivalently: ```bash make -C infra prod-up ``` | Service | Image | Published port | Role | | --- | --- | --- | --- | | `postgres` | `postgres:16-alpine` | `127.0.0.1:5432` | control plane state | | `redis` | `redis:7-alpine` | `127.0.0.1:6379` | snapshots, auth cache, usage streams | | `migrate` | control image, `migrate` | — | runs `alembic upgrade head`, then exits | | `control` | control image, `api` | `8000` | FastAPI control plane | | `control-worker` | control image, `worker` | — | arq worker: usage, audit, publication | | `gateway` | gateway image | `8080` | Rust data plane, `/v1/*` and `/mcp` | | `web` | web image | `127.0.0.1:3001` | Next.js dashboard | | `landing` | landing image | `127.0.0.1:3000` | marketing site, no backend dependency | The control image is one image with three roles selected by the container command: `api`, `worker`, and `migrate`. `apps/control/docker-entrypoint.sh` dispatches them. There is no `connectors` service, in any target. Out-of-process Python workers are unimplemented — `apps/connectors/` ships no package and no image — so the service, its `connector-workers` Compose profile, the Fly app and the Helm workload were all removed rather than left as a profile that could not start. Reviewed native connector packs run through the gateway's own HTTP executor and need no worker. See [Trusted workers](/connectors/trusted-workers). ### Boundaries the topology enforces - **The gateway has no `DATABASE_URL`.** Postgres never sits on the streaming path; the gateway reads snapshots from Redis and falls back to control's `/internal/v1/*`. - **Only the gateway can unwrap a credential.** No other workload receives a vendor secret or key-encryption key, and no target sets `OBOL_CONNECTORS_URL`. Were a worker deployment added, it would need a private-only address and an ingress policy admitting the gateway alone — and it would today receive the vendor credential over ADR-0014's hand-off, which is why one has not been. - **Control's `/internal/*` is never routed by the public edge.** Under Compose the whole control port is published, so put control behind a reverse proxy that allows only `/api`, `/oauth`, `/.well-known`, and `/healthz` when it is reachable from outside the host. These are the same [invariants](/concepts/invariants) the code enforces, restated at the infrastructure layer. ## Required configuration `infra/.env` is the only file you edit. `make -C infra env` seeds it from `infra/.env.example`. ### Secrets you must set | Variable | Generate with | Used by | | --- | --- | --- | | `POSTGRES_PASSWORD` | `openssl rand -hex 24` | postgres, control, worker, migrate | | `OBOL_SERVICE_JWT_SECRET` | `openssl rand -hex 32` | gateway, control, worker, migrate | | `CLERK_ISSUER` | Clerk dashboard | control | | `CLERK_AUTHORIZED_PARTIES` | your dashboard origins | control | | `CLERK_SECRET_KEY` | Clerk dashboard | web | | `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | Clerk dashboard | web, at image build time | Compose refuses to start without `POSTGRES_PASSWORD` and `OBOL_SERVICE_JWT_SECRET`; neither has a default. With no `CLERK_ISSUER` set, control answers `503` on every `/api/v1` operator route rather than serving them unauthenticated. That is deliberate. A control plane that silently opens because a variable was unset is the worst available outcome. `NEXT_PUBLIC_*` values are compiled into the dashboard image, so a runtime environment variable cannot change them. Rebuild `web` after changing the publishable key or the public URLs. ### Key-encryption key `OBOL_KEK_PROVIDER` selects how vaulted credentials are wrapped. ```bash Local age (self-host default) OBOL_KEK_PROVIDER=local OBOL_AGE_KEY_FILE=./secrets/age.key OBOL_AGE_RECIPIENT=age1... ``` ```bash AWS KMS OBOL_KEK_PROVIDER=aws OBOL_KMS_KEY_ID=arn:aws:kms:us-east-1:...:key/... AWS_REGION=us-east-1 ``` ```bash GCP KMS OBOL_KEK_PROVIDER=gcp OBOL_KMS_KEY_NAME=projects/.../cryptoKeys/... ``` `make -C infra age-key` generates the local identity and prints its public recipient. The private identity is mounted as a Compose secret into the gateway only, at `/run/secrets/age.key`. Control gets `OBOL_AGE_RECIPIENT` and can therefore seal a credential but never open one. Connectors and web receive no key material at all. See [vault and key management](/security/vault). ### Optional configuration | Variable | Default | Effect | | --- | --- | --- | | `OBOL_IDEMPOTENCY_TTL_S` | `86400` | Gateway idempotency window; maximum `604800` | | `OBOL_FORWARDED_ALLOW_IPS` | `127.0.0.1,::1` | Proxies uvicorn trusts for forwarded headers | | `OTEL_EXPORTER_OTLP_ENDPOINT` | empty | OTLP destination for all services | | `RUST_LOG` / `LOG_LEVEL` | `info` | Gateway / Python log levels | | `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` | empty | Metered billing delivery | | `NANGO_SECRET_KEY`, `COMPOSIO_API_KEY` | empty | Catalog broker registration | Never set `OBOL_FORWARDED_ALLOW_IPS` to `*`. Scope it to the exact proxy addresses or ranges in front of control. Leaving the Stripe variables blank is a supported self-host posture: usage is still recorded, and only delivery to Stripe waits. See [metering](/billing/metering). ## Database migrations `migrate` runs `alembic upgrade head` and exits. Both `control` and `control-worker` depend on it with the `service_completed_successfully` condition, so no application process starts against an un-migrated schema. Control also has a `/healthz` healthcheck the gateway waits on before it starts. The Kubernetes chart applies the same rule differently — a revision-named Job plus an init container that waits for the image's full Alembic head set (ADR-0043). See [Helm](/deploy/helm). ## Upgrading Migrations must stay compatible with the application revision still running during a rollout. A breaking schema change needs a staged migration plan, not a faster restart. ```bash docker compose -f infra/docker-compose.yml --env-file infra/.env \ exec postgres pg_dump -U obol obol > obol-backup.sql ``` ```bash git pull make -C infra prod-up ``` `prod-up` rebuilds the images and recreates changed services. `migrate` runs before `control` and `control-worker` come back. ```bash make -C infra ps curl -fsS http://localhost:8080/startupz # Redis up; 200 even with no snapshots curl -fsS http://localhost:8080/readyz # 200 once a workspace has published; 503 on a fresh install curl -fsS http://localhost:8000/healthz ``` `/startupz` is the first-boot probe: Compose's gateway healthcheck uses it so an empty install becomes healthy, and the self-host smoke check fails if it returns 503 (Redis gone or draining). `/readyz` is traffic readiness and still requires at least one policy snapshot — Helm and Fly keep it as the load-balancer probe because those installs have a snapshot before customer traffic. `/healthz` is liveness only (always 200). See [Telemetry](/observability/telemetry). Never erase a database to repair a failed deployment. Inspect the `migrate` service logs first — a failed migration is a diagnosable state, and the data behind it is the audit record. ### Upgrading a development stack The development control image synchronizes its locked Python dependencies at startup (`OBOL_DEV_SYNC_DEPS=true`) into a persistent `.venv` volume. Rebuilding the image alone does not refresh that volume. After changing control dependencies or migrations: ```bash docker compose -f infra/docker-compose.yml -f infra/docker-compose.dev.yml \ --env-file infra/.env up --build -d migrate control control-worker ``` Python hot reload watches application source only. It does not install dependencies and it does not apply migrations. Production images keep their baked environment and install nothing at startup. ## Data and state | Volume | Holds | | --- | --- | | `pgdata` | Postgres data directory | | `redisdata` | Redis append-only file | Redis runs with `--appendonly yes` and `--maxmemory-policy noeviction`. The eviction policy is not incidental: snapshots and idempotency records must not be evicted under memory pressure. Back up `pgdata`. Redis is a cache and a stream buffer in front of Postgres, but usage events in flight are only in Redis until the worker drains them, so drain before a destructive Redis operation. ## Next steps Service-by-service detail on the Compose files and Makefile targets. The Kubernetes self-host path. The rules the deployment is shaped to enforce. Metrics, traces, and where they go.