Use the self-host installer for the free customer-operated edition. Contracted Enterprise deployment and support are available separately.

Customer installation

Install the prebuilt release as described in self-hosting. The downloaded docker-compose.yml pins ghcr.io/tryobol/gateway, control and web, plus Postgres 16-alpine and Redis 7-alpine, by digest. It contains no build steps. Configuration lives in the installation’s protected .env.
The release’s Compose project defaults to obol-selfhost. Its public helper preserves that project, database and encryption keys during upgrades and runs migrations before Control and the worker. Fly.io is an Obol-operated hosting path, separate from the self-host distribution.

Contributor source workflow

The remaining reference describes infra/docker-compose.yml and its source build overlays for contributors. Customer installations use the downloaded release file and commands above. The release renderer derives the same service topology from this file and replaces build definitions with immutable images. 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 imports control’s settings, which refuse to load outside dev without the service secret and the ADR-0131 key set, so it receives DATABASE_URL, the common environment, the self-host deployment facts, and control’s own OBOL_SERVICE_JWT_SECRET, OBOL_SERVICE_SIGNING_KEY and the gateway and Workers public keys — and nothing else: no KEK, no gateway or Workers seed. 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 ${OBOL_BIND_ADDRESS:-127.0.0.1}:${CONTROL_PORT:-8000}:8000 — loopback unless the operator widens OBOL_BIND_ADDRESS. 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. In clerk mode with CLERK_ISSUER unset, /api/v1 routes answer 503; local mode (Free Self-hosted) needs no Clerk configuration.

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 ${OBOL_BIND_ADDRESS:-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. It is behind the landing Compose profile, so a plain up neither builds nor starts it; make -C infra up passes --profile landing.

Built-in tools and SearXNG

builtin-tools (Obol’s built-in tools service, port 8095) and searxng (a pinned, unmodified SearXNG image, port 8080) sit behind the builtin-tools Compose profile. They are not in the customer installation’s release file, and a plain up neither builds nor starts them. Neither publishes a host port. The gateway and both control processes reach the service on the builtin-tools network; SearXNG is on builtin-search with the service and nothing else, and neither service joins the network Postgres and Redis use. To run them from a source checkout, set the “Obol built-in tools” names in infra/.env (the service URL and token for control, the same origin for the gateway’s egress exemption, and a SearXNG secret) and pass the profile:
With the profile off, control’s OBOL_BUILTIN_TOOLS_URL renders blank and no workspace snapshot carries a built-in tool. With it on, the control worker provisions each workspace’s system-managed connection at startup and every five minutes. Workspaces that existed before the built-in tools migration keep the tools off until an owner or admin turns them on (see Built-in tools). The profile is for development from a source checkout. Control configures built-in tools only on Obol Cloud and on a development deployment (OBOL_ENV=dev, which make up sets); with any other OBOL_ENV, such as the Free Self-hosted release’s prod, no workspace carries a built-in tool even with the profile running. Obol Workers and built-in tools are Obol Cloud services. They are not part of Free Self-hosted (ADR-0215). On a development deployment every built-in call is recorded not_chargeable, and you supply your own upstream accounts; Obol never ships its keys.

Ports

The control port is published whole on OBOL_BIND_ADDRESS. /internal/* must never be routed by a public edge — when control is reachable from outside the host (a widened OBOL_BIND_ADDRESS or a proxy in front), 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.