---
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.