Self-hosted deployments are available on the Enterprise plan. See self-hosting.
infra/docker-compose.yml is the canonical topology. It is simultaneously the development environment and the single-host self-host artifact (ADR-0009), which means drift between the two would have to appear as a Compose change and would be visible in review. Three files exist: The Compose project is named obol; the test project is named obol-test, so the two never collide.

Makefile targets

Every target runs from infra/. make -C infra <target> invokes them from anywhere in the repository. up and prod-up both depend on env and age-key, so a first run generates what it needs.

Images

Every image is built from apps/<app>/Dockerfile with the repository root as build context, because the builds need packages/. Image names come from OBOL_IMAGE_PREFIX (default ghcr.io/obol) and OBOL_TAG (default dev). The control image contains one Rust artifact, the vendored obol-policy-check helper, exposed as OBOL_POLICY_CHECK_BIN.

Services

postgres

Database obol, user obol, password from POSTGRES_PASSWORD. The password variable is declared with :?, so Compose refuses to render without it. Healthcheck is pg_isready -U obol -d obol every 5s, 20 retries.

redis

noeviction is load-bearing. Snapshots, the auth cache, and idempotency records must not be evicted under memory pressure.

migrate

The control image with command migrate, which the entrypoint dispatches to alembic upgrade head. It has only DATABASE_URL and the common environment — no KEK, no service JWT. restart: "no", and it waits for postgres to be healthy. Both control and control-worker depend on migrate: {condition: service_completed_successfully}.

control

The control image with command api: uvicorn on 0.0.0.0:8000 with --proxy-headers and --forwarded-allow-ips from OBOL_FORWARDED_ALLOW_IPS. Published on ${CONTROL_PORT:-8000}:8000 on all interfaces. Healthcheck is curl -fsS http://localhost:8000/healthz.
CLERK_SECRET_KEY is passed to control but unused there. Control verifies operator sessions against the issuer’s published JWKS, which needs no provider secret. With CLERK_ISSUER unset, /api/v1 routes answer 503.

control-worker

The control image with command worker, running arq app.workers.WorkerSettings. It publishes no port. Beyond the database, Redis, and KEK configuration it adds the stream-consumer settings: The audit consumer drains stream:invocation-audit:{workspace_id} into the receipts read model, off the request path by construction.

gateway

There is deliberately no DATABASE_URL. The age.key Compose secret is sourced from OBOL_AGE_KEY_FILE on the host and lands at /run/secrets/age.key inside the gateway and nowhere else. The image is distroless and carries no curl, so the healthcheck uses the binary’s own subcommand:
Memory is limited to 512m. The metrics listener defaults to 0.0.0.0:9091 (OBOL_METRICS_ADDR) and is not published to the host by the Compose file. Routes reachable on the published port: /v1/chat/completions, /v1/responses, /v1/embeddings, /v1/messages, /v1/route, /mcp, /webhooks/{workspace}/{connection}/{subscription}, /hooks/..., /healthz, /readyz, /startupz, /metrics. See the gateway overview.

There is no connectors service

The stack defines no connector-worker service, and the gateway sets no OBOL_CONNECTORS_URL. apps/connectors/ ships no package and no Dockerfile, so the previous connectors service was a build recipe for source that does not exist — docker compose up --build could not complete its build graph. The service, its connector-workers profile, and the OBOL_VENDOR_SANDBOX variable it carried were removed rather than replaced with a stub.
Every reviewed connector pack executes in the gateway’s in-process OpenAPI executor; remote MCP servers federate directly. Neither path needs a worker. apps/connectors/README.md records what must exist before one returns, and infra/tests/test_configuration.py fails the build if the service reappears. See Trusted workers.

web

The dashboard, published on 127.0.0.1:${WEB_PORT:-3001}:3000. It talks only to control. Build arguments (compiled into the bundle): NEXT_PUBLIC_CONTROL_URL, NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, NEXT_PUBLIC_MARKETING_URL, NEXT_PUBLIC_APP_URL. Runtime environment adds CONTROL_URL=http://control:8000 for server-side calls that stay on the Compose network, plus CLERK_SECRET_KEY. The empty default for the publishable key keeps docker compose config renderable in CI without a Clerk instance; the web Dockerfile rejects an empty key when the image is actually built.

landing

The marketing site, published on 127.0.0.1:${LANDING_PORT:-3000}:3000. No backend dependency; WAITLIST_WEBHOOK_URL is optional.

Ports

The control port is published on all interfaces. /internal/* must never be routed by a public edge — when control is reachable from outside the host, put a reverse proxy in front that allows only /api, /oauth, /.well-known, and /healthz.

Volumes

The development overlay

docker-compose.dev.yml does three things per service: select the Dockerfile’s dev stage, bind-mount the source, and replace the command with a watching one. Postgres and Redis are untouched — there is nothing to reload. Two mount rules make this work rather than break:
  1. The source is mounted over the workdir, so a host edit is visible inside.
  2. Build outputs the host does not own — node_modules, .next, .venv, target — are named volumes mounted on top, so the image’s copies survive.
migrate is also bind-mounted under the overlay. Without that it would run the baked image beside a bind-mounted control and fail with “Can’t locate revision identified by …” against a database its sibling migrated. The dev gateway healthcheck points at /src/target/debug/obol-gateway, matching the debug artifact its command builds, and raises the memory limit to ${GATEWAY_DEV_MEMORY:-6g}rustc was OOM-killed at 512m. CARGO_BUILD_JOBS defaults to 4 so a rebuild cannot starve the rest of the stack. web deliberately does not mount packages/. Turbopack will not resolve outside its project root, which it infers from apps/web/pnpm-lock.yaml; anything the dashboard needs from packages/ is mirrored into apps/web at check-in by a sync script with a drift gate. Control and the gateway have no such restriction and mount it directly.

The test overlay

docker-compose.test.yml runs one redis:7-alpine on 127.0.0.1:6380 with persistence off (--save "", --appendonly no) and noeviction. It is the real Redis the gateway test suite runs against. Test isolation is in-process, by per-test key prefix — the suite never calls FLUSHDB.

Validating without starting anything

infra/tests/ checks Compose configuration, Helm rendering, and the database gate with local mocks only. No command there contacts a deployed service.

Self-hosting

Required secrets, boundaries, and upgrade procedure.

Quickstart

From an empty checkout to a first authenticated call.