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