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 downloadeddocker-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.
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 describesinfra/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 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 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 commandapi: 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 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 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 on127.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:
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
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.