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 frominfra/. 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 fromapps/<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
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 commandmigrate, 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 commandapi: 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 commandworker, 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
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:
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
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 on127.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 on127.0.0.1:${LANDING_PORT:-3000}:3000. No
backend dependency; WAITLIST_WEBHOOK_URL is optional.
Ports
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:
- The source is mounted over the workdir, so a host edit is visible inside.
- 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.
Related
Self-hosting
Required secrets, boundaries, and upgrade procedure.
Quickstart
From an empty checkout to a first authenticated call.