--- title: "Docker Compose" description: "Reference for infra/docker-compose.yml and its development and test overlays: services, ports, volumes, environment, and Makefile targets." --- Self-hosted deployments are available on the Enterprise plan. See [self-hosting](/get-started/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: | File | Purpose | | --- | --- | | `docker-compose.yml` | The stack as it ships. Production images, no source tree, no dev servers. | | `docker-compose.dev.yml` | Overlay: `dev` build stages, bind-mounted source, watching commands. | | `docker-compose.test.yml` | A standalone Redis on port 6380 for the gateway test suite. | 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 ` invokes them from anywhere in the repository. | Target | Command it runs | | --- | --- | | `env` | Copies `.env.example` to `.env` if absent | | `age-key` | Generates `secrets/age.key` (mode 600), prints `OBOL_AGE_RECIPIENT` | | `up` | `docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env up --build -d` | | `prod-up` | `docker compose -f docker-compose.yml --env-file .env up --build -d` | | `down` | Stops the dev stack | | `logs` | `logs -f --tail=200` | | `ps` | Service state | | `build` | Builds images without starting | | `psql` | `exec postgres psql -U obol obol` | | `redis-cli` | `exec redis redis-cli` | | `helm-lint` | Builds chart dependencies, lints and renders both Helm profiles | | `fly-deploy` | Deploys control, gateway, and web to Fly | | `test-redis` | Starts the test Redis on 6380 | | `test-redis-down` | Stops it | `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//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`). | Service | Dockerfile | Runtime base | | --- | --- | --- | | gateway | `apps/gateway/Dockerfile` | `gcr.io/distroless/cc-debian12:nonroot` | | control, control-worker, migrate | `apps/control/Dockerfile` | `python:3.12-slim-bookworm`, runs as uid 10001 | | web | `apps/web/Dockerfile` | — | | landing | `apps/landing/Dockerfile` | — | The control image contains one Rust artifact, the vendored `obol-policy-check` helper, exposed as `OBOL_POLICY_CHECK_BIN`. ## Services ### postgres ```yaml image: postgres:16-alpine ports: ["127.0.0.1:${POSTGRES_PORT:-5432}:5432"] volumes: [pgdata:/var/lib/postgresql/data] ``` 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 ```yaml image: redis:7-alpine command: ["redis-server", "--appendonly", "yes", "--maxmemory-policy", "noeviction"] ports: ["127.0.0.1:${REDIS_PORT:-6379}:6379"] volumes: [redisdata:/data] ``` `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`. | Variable | Value in Compose | | --- | --- | | `DATABASE_URL` | `postgresql+psycopg://obol:***@postgres:5432/obol` | | `REDIS_URL` | `redis://redis:6379/0` | | `OBOL_PUBLIC_URL` | `OBOL_CONTROL_PUBLIC_URL`, default `http://localhost:8000` | | `OBOL_WEB_URL` | `OBOL_WEB_PUBLIC_URL`, default `http://localhost:3001` | | `OBOL_GATEWAY_INTERNAL_URL` | `http://gateway:8080` | | `OBOL_GATEWAY_PUBLIC_URL` | what an operator pastes into an IDE | | `OBOL_MCP_JWT_ISSUER` | the control public URL | | `OBOL_SERVICE_JWT_SECRET` | required, no default | | `CLERK_ISSUER`, `CLERK_AUTHORIZED_PARTIES`, `CLERK_SECRET_KEY` | dashboard identity | | `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` | optional | | `OBOL_KEK_PROVIDER`, `OBOL_AGE_RECIPIENT` | encrypt-only KEK configuration | 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: | Variable | Default | | --- | --- | | `OBOL_USAGE_CONSUMER_GROUP` | `control-usage` | | `OBOL_USAGE_CONSUMER_NAME` | `control-worker-1` | | `OBOL_USAGE_BATCH` | `256` | | `OBOL_USAGE_CLAIM_IDLE_MS` | `60000` | | `OBOL_AUDIT_CONSUMER_GROUP` | `control-audit` | | `OBOL_AUDIT_CONSUMER_NAME` | `control-worker-1` | | `OBOL_STRIPE_METER_EVENT_NAME` | `obol_governed_tool_calls` | | `OBOL_STRIPE_METER_BATCH` | `100` | | `STRIPE_API_BASE` | `https://api.stripe.com` | The audit consumer drains `stream:invocation-audit:{workspace_id}` into the receipts read model, off the request path by construction. ### gateway ```yaml environment: OBOL_LISTEN_ADDR: 0.0.0.0:8080 OBOL_CONTROL_URL: http://control:8000 OBOL_MCP_JWT_ISSUER: ${OBOL_CONTROL_PUBLIC_URL:-http://localhost:8000} OBOL_MCP_JWT_AUDIENCE: ${OBOL_GATEWAY_PUBLIC_URL:-http://localhost:8080} OBOL_IDEMPOTENCY_TTL_S: ${OBOL_IDEMPOTENCY_TTL_S:-86400} secrets: [age.key] ports: ["${GATEWAY_PORT:-8080}:8080"] ``` 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: ```bash /usr/local/bin/obol-gateway healthcheck http://localhost:8080/startupz ``` 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](/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](/connectors/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 | Service | Container | Host | Public | | --- | --- | --- | --- | | gateway | 8080 (+9091 metrics) | `${GATEWAY_PORT:-8080}` | yes | | control api | 8000 | `${CONTROL_PORT:-8000}` | `/api`, `/oauth`, `/.well-known` only | | control worker | — | — | no | | web | 3000 | `127.0.0.1:${WEB_PORT:-3001}` | via the deployment edge | | landing | 3000 | `127.0.0.1:${LANDING_PORT:-3000}` | via the deployment edge | | postgres | 5432 | `127.0.0.1:${POSTGRES_PORT:-5432}` | no | | redis | 6379 | `127.0.0.1:${REDIS_PORT:-6379}` | no | 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 | Volume | Mounted at | Defined in | | --- | --- | --- | | `pgdata` | `/var/lib/postgresql/data` | base | | `redisdata` | `/data` | base | | `control-venv` | `/app/.venv` | dev overlay | | `gateway-target` | `/src/target` | dev overlay | | `gateway-cargo` | `/usr/local/cargo/registry` | dev overlay | | `web-node-modules`, `web-next` | `/app/node_modules`, `/app/.next` | dev overlay | | `landing-node-modules`, `landing-next` | same, for landing | dev overlay | ## 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. | Service | Command under the overlay | | --- | --- | | `control` | `api --reload --reload-dir /app/app` | | `control-worker` | `watchfiles --filter python "arq app.workers.WorkerSettings" /app/app` | | `gateway` | `cargo watch --poll -w crates -w ../../packages -x "run --locked -p obol-gateway"` | | `web`, `landing` | Next.js dev servers with `NODE_ENV=development` and `CI=true` | `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 ```bash make -C infra test-redis # OBOL_TEST_REDIS_URL=redis://127.0.0.1:6380 make -C infra test-redis-down ``` `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 ```bash uv run --frozen --project apps/control --with pyyaml==6.0.3 \ python -m unittest discover -s infra/tests -v ``` `infra/tests/` checks Compose configuration, Helm rendering, and the database gate with local mocks only. No command there contacts a deployed service. ## Related Required secrets, boundaries, and upgrade procedure. From an empty checkout to a first authenticated call.