The repository's Compose files: what each one is, how to run it, and the rationale that used to
sit inline in docker-compose.yml. This page is the single index; the files themselves carry the
operational one-liners. archive/notes/kb-migration-2026-10-11.md records the split out of the old single
file, and the 2026-10-14 move of the optional stacks under docker/compose/.
| File | Services | When you need it |
|---|---|---|
docker-compose.yml (repo root) |
postgres, migrate, agent-computer, agent-sandbox, openbot-redis, spire-init / spire-server / spire-agent, supervisor, agent-bot, agent-harness (profile harness), agent-langgraph, agent-trpc, go-runtime (profile go-runtime) |
Always, first. scripts/start.sh drives this file. |
docker/compose/services.yml |
casdoor, open-connector |
When CASDOOR_OAUTH_* is configured, or the self-hosted OpenConnector broker should run. |
docker/compose/lago.yml |
lago-redis, lago-pdf, lago-migrate, lago-api, lago-front, lago-worker, lago-clock |
When billing talks to Lago (LAGO_API_URL). |
docker/compose/observability.yml |
loki, tempo, prometheus, alloy, grafana, glitchtip (+ its own postgres and redis) |
When local telemetry is wanted — observability.md is the guide. |
docker/compose/certs.yml |
openbot-tls |
When the preview origin runs on a public wildcard host (OPENBOT_CRAFT_PREVIEW_ORIGIN_TEMPLATE) — tls-certs.md is the guide. |
Run from the repository root, and pass --project-directory . for every file under
docker/compose/:
docker compose up -d # the main stack
docker compose --project-directory . -f docker/compose/services.yml up -d
docker compose --project-directory . -f docker/compose/lago.yml up -d
docker compose --project-directory . -f docker/compose/observability.yml up -d
docker compose --project-directory . -f docker/compose/certs.yml up -dWhy --project-directory . is not optional. Compose derives the project directory from the
first -f file's location, and the project directory is what decides two things: which
interpolation .env is read (the repo root's), and what the ./docker/... and ./casdoor/...
mount sources inside these files point at. With the flag, a file under docker/compose/ resolves
exactly as it did when it lived at the root — same mounts, same .env. Without it, the root .env
is not read at all: Lago fails loudly on its missing env_file, GlitchTip loudly on its required
GLITCHTIP_SECRET_KEY.
Start the main stack first. These files share the main project's data network and reach
postgres by service name; cross-file depends_on is not a thing Compose resolves, so the restart
policies absorb a postgres that is not up yet (a container that starts early crash-loops briefly
and then comes up). lago-migrate is the one-shot exception — it exits on a dead database rather
than retrying, so run it against a healthy main stack.
The optional files are outside default discovery on purpose. A plain docker compose up -d on
the main file never starts any of them; each is brought up by its own -f invocation. They all
carry name: openbot, the main file's project name, so volumes keep their openbot_* names and a
stack that ran under the old single file keeps its data (qdrant-data, lago-redis-data,
open-connector-data, loki-data, …) without a migration step.
Every published port binds loopback only (127.0.0.1 and [::1]), and every one has a *_PORT
override named in .env.example (the craft dev range is the OPENBOT_CRAFT_SANDBOX_NEXTJS_PORT_[START|END] pair).
| Stack | Loopback ports |
|---|---|
| main | postgres 5432, agent-computer 4100, supervisor 4500→4300, agent-bot 4200, agent-langgraph 4201, agent-harness 4202 (picked), agent-trpc 4204, go-runtime 4203, agent-sandbox 4300 + craft dev range 3011-3098, openbot-redis 6379 |
| services | casdoor 8000, open-connector 3100 |
| lago | lago-api 48889, lago-front 48890 |
| observability | grafana 3003, loki 3102, tempo 3200 (+ OTLP 4318/4317), prometheus 9090, alloy 12345, glitchtip 8001 |
Plain PostgreSQL plus pg_partman, built from docker/postgres-partman.Dockerfile. The pgvector
base image is gone — embeddings moved to Qdrant and migration 0010 dropped the last Postgres-side
vector column — but a deployment starting from nothing still replays migration 0000, which creates
chunks.embedding and fails on a stock image with extension "vector" is not available. So
vector stays, installed from the PGDG apt repo the base image already carries. partman is for
Lago: it installs from db/structure.sql and unconditionally drops and recreates pg_partman, so
the binary has to be present in the server or that load fails. The data volume this stack already
runs on is unaffected by the image swap.
Three networks. data carries the database and the services that reach it by name — postgres,
migrate, openbot-redis, plus the optional stacks' services. default carries everything else:
the Bots, their computers, the supervisor, SPIRE. openbot_sandbox is the sandbox's own bridge.
The point of the split: with no networks: anywhere, Compose puts every service on one network
with DNS by service name, so agent-computer — the one container whose whole job is to run what a
Bot asks for, shell included — could open postgres:5432 with the credentials in this file. The
published loopback port remains the host-side contract; taking postgres off the Bots' network
removes the name, not the localhost address. A deployment that runs the API server inside Compose
puts that service on both data and default, which is the one place the two are meant to meet.
Every published port binds 127.0.0.1 and [::1], not just one. localhost resolves to ::1 or
127.0.0.1 in an order the platform decides, and Bun and Node both fail the connection outright
rather than trying the other family — publishing on one makes localhost work on some machines
and not others, for a reason nothing in the error mentions. Publishing on both is still loopback.
Each built service has an explicit image name (openbot-postgres, openbot-server,
openbot-agent-computer, …) and pull_policy: ${IMAGE_PULL_POLICY:-build}. A release publishes
those images, and a machine that can name one does not need a toolchain to build it; a service
carrying build: builds by default however its image is named, so the policy is what picks
between the two. IMAGE_PULL_POLICY=missing makes a deployment pull instead. A failed pull still
falls back to building, silently — right for a developer, wrong for an installed OpenBot with no
toolchain, so installers override the build section away rather than relying on the variable.
Three init scripts mount into the postgres entrypoint — casdoor/init-db.sql, docker/lago/init-db.sql,
docker/openconnector/init-db.sql — one login role and one database
each, so none of these tenants shares a schema with OpenBot's own tables. They are read-only and
run once, on the first boot of a fresh data volume only; the volume this stack already runs on had
them applied by hand, because init scripts never re-run against existing data.
agent-computer and supervisor read egress.env (env_file: ./egress.env, required: false).
The names are per-Bot (EGRESS_PROXY_<BOT> is derived from the Bot's id, plus EGRESS_PROXY_DEFAULT),
so there is no fixed list to write into the file; and it is not .env because that one holds the
deployment's secrets and the browser container is deliberately not given them. The supervisor does
not read the values itself: it forwards every EGRESS_PROXY key out of its own environment into
each computer it creates, so it has to be given them first. Optional, because a proxy-less
deployment is the ordinary case and must still start.
The supervisor holds the Docker socket — root-equivalent — so only it gets the socket, its port is
loopback-only, and SUPERVISOR_TOKEN is a shared secret rather than a network boundary. The mount
is read-only because the service only ever needs to ask. ENGINE_SOCKET is a setting because the
default path is wrong on rootless Podman on Linux: the path is either absent or, with podman-docker
installed, a symlink to the rootful socket, and the supervisor then holds a dead socket and reports
that it cannot reach Docker. Point ENGINE_SOCKET at $XDG_RUNTIME_DIR/podman/podman.sock there.
security_opt: label=disable because the engine socket carries an SELinux label the container is
not allowed to read.
agent-harness is ONE service rather than twelve: a picked harness is a choice, and the image and
port come from .env, which the setup shell writes from the pick — each harness fixes its own port
in its own Dockerfile. It sits behind the harness profile so a deployment that has picked nothing
does not try to start it; without that, an unset PICKED_HARNESS_IMAGE becomes a request to pull
the empty string, which fails the whole compose up rather than the one service nobody asked for.
No build: section, so pull_policy is missing here: this image is only ever published, never
built from this tree.
Optional workload identity. SPIRE issues each computer an SVID based on the container labels the
supervisor sets, so a computer cannot claim another Bot's identity and an unregistered container
receives no identity. The agent runs with pid: host because its attestor reads the peer cgroup
from the host pid namespace, and it mounts the Docker socket read-only only so the workload
attestor can see which container is asking. spire-init chowns the fresh named volumes (they
arrive owned by root) so neither the CA nor the agent runs as root. Without these services the
product runs without workload identity and reports that state explicitly.
The Go runtime candidate is off by default behind the go-runtime profile:
docker compose --profile go-runtime up go-runtime. It serves the enterprise wire
(three-header authenticated execution/cancel), validates identity against the enterprise entry's
/internal/enterprise/v1/runtime/validate, and calls governed tools back through
/internal/enterprise/v1/tool-invocations. The host-run enterprise entry is its only caller, so
the port is loopback-only. Nothing here touches the legacy deployment's bot registry.
Three Redis instances, named to stay unmistakable: openbot-redis (this stack — workshop turn locks
and event fan-out, pure memory on purpose: persisting it would resurrect locks whose owner process
is long gone), lago-redis and glitchtip-redis (each owned by the stack that names it).
agent-sandbox publishes SANDBOX_PORT and the Craft webapp dev range. Craft's per-session dev
servers bind OPENBOT_CRAFT_SANDBOX_NEXTJS_PORT_[START|END] inside the container, and the API
server on the host probes/previews them through the gateway base, so the range publishes to
loopback exactly like the gateway port.
The range has one source: the two .env keys. docker-compose.yml publishes START-END on
the host and the container side from the same interpolation — defaults 3010/3100, matching the
server's own defaults in server/src/craft/config.ts — so even with no .env at all the
published range equals the range the server allocates from. .env.example ships 3011/3098
instead, because 3010 collides with the vite dev server and a fresh checkout copies the example.
To change the range, edit the two keys in .env and run docker compose up -d agent-sandbox
again (the container is recreated with the new publish; restart a host-run API too — it reads
the keys at startup).
Before the stack comes up, bun --no-env-file scripts/craft-port-range-preflight.ts answers
the question Compose otherwise answers badly. It reads the range from the two places compose
reads it — the environment the caller exported, then the repository root .env (parsed with
compose's rules, inline comments included) — and falls back to the 3010/3100 defaults. The
--no-env-file flag is part of that contract: Bun by itself preloads the whole .env family of
the working directory into the environment — .env.local and .env.<NODE_ENV> variants compose
never reads — so without the flag the preflight's highest-priority source would be files compose
does not use. Every port in the range is checked on both loopback families. A port held by this
stack's own agent-sandbox — same Compose service and same project, judged against the
com.docker.compose.project label with the project compose itself would derive
(COMPOSE_PROJECT_NAME, else the normalized repository directory name; the compose file has no
name:) — prints as ours and passes. scripts/start.sh runs the preflight before its first
docker compose up and, on a conflict, warns without aborting — the hard gates are the preflight
CLI itself and acceptance step 0b, the paths that actually publish the range — so a rerun stays
safe even when a held port inside the range is one the core stack itself started (the app's own
3010, inside the key-less default range). A same-named agent-sandbox from another checkout
is a different project and is reported as a conflict. Anything else is listed with its holder —
the container from docker ps, with image, Compose service and project, or the process from
lsof, with name and pid — followed by the two fixes: move the range and bring the sandbox up
again, or free the port. Where lsof or docker is missing the preflight says so per probe
surface instead of reporting an empty, green read (an lsof that fails outright or prints a
warning counts as missing too), and it refuses to certify the range when neither surface could be
checked at all. A failing run looks like this:
agent-sandbox 端口段 3010-3100(91 个端口):127.0.0.1 与 [::1] 共 1 处占用
- 端口 3050(127.0.0.1):Python (pid=14233)
解决(两选一,修完重跑 bun --no-env-file scripts/craft-port-range-preflight.ts 验证):
1. 改段:编辑 .env 的 OPENBOT_CRAFT_SANDBOX_NEXTJS_PORT_START / OPENBOT_CRAFT_SANDBOX_NEXTJS_PORT_END(当前 3010/3100),再执行
docker compose up -d agent-sandbox
宿主上直接跑的 API server 也要重启 —— 它在启动时读取这两个键。
2. 停占用:先定位占用者 ——
docker ps --filter publish=3050
lsof -nP -iTCP:3050 -sTCP:LISTEN
确认后停止:docker stop <容器名> 或 kill <pid>。
The configured range is checked a second time against reality by the acceptance preflight:
bun run test:craft-preview-e2e reads the ports the running sandbox actually publishes
(docker port) and fails loud when one in the range is missing — the "stack and .env disagree"
case (AC3), reported with both sides and the command that fixes it. Once docker compose up is
already failing, lsof -nP -iTCP:<port> -sTCP:LISTEN and docker ps --filter publish=<port>
still name the holder; free it or move the range.
Production never publishes this range to a host: the orchestrator assigns sandbox ports there, and the loopback publish is a dev-stack concern only.
casdoor is the identity provider OpenBot signs in through when CASDOOR_OAUTH_* is configured:
stateless (everything it owns lives in postgres, through its own database and login role),
reached by browsers at the loopback address the sign-in page is served on, plus the app.conf
mount. open-connector is the self-hosted broker behind the deployment's own app catalogue: the
credentials people type stay in its volume, encrypted with OPENCONNECTOR_ENCRYPTION_KEY, and the
two tokens are the boundary against everything else on the machine. Its database default
(OOMOL_CONNECT_DATABASE_URL) points at the openconnector database on the shared postgres; a
fresh database needs an explicit migrate before first start:
docker compose --project-directory . -f docker/compose/services.yml run --rm open-connector npm run runtime:migrate.
The image is latest today and gets pinned to a digest when cutting a release.
Digest-pinned to getlago/lago v1.53.0, the release OpenBot's client and webhook verifier were
integration-tested against. Secrets never live in the compose file: they come from
docker/lago/.env (generated once by docker/lago/init-env.sh) through each service's env_file,
and the database is the shared postgres through the dedicated lago database and login role.
Partman-less on purpose — docker/lago/init-db.sql has what that trades away and why.
lago-migrate runs Lago's migrations and, on the very first boot, mints the operator account and
organization API key from the LAGO_ORG_* seed values.
The LGTM five (Loki, Tempo, Prometheus, Alloy, Grafana) plus GlitchTip with its own postgres and
redis, split out of the main file the same way Lago was — a plain docker compose up -d never
starts any of it. observability.md is the guide (URLs, queries, DSNs, the protocol test); the
compose-level facts: Alloy runs as root so the read-only docker.sock is openable whatever gid it
carries and tails every container plus the host's .logs/; Grafana's datasources and starter
dashboard are provisioned from docker/observability/; GlitchTip owns its data and wipes it on
upgrade, which is why it runs beside — not on — the shared postgres; GLITCHTIP_SECRET_KEY is
forced because Django signs sessions with it; everything keeps seven days.
bunx e2e run --config e2e.unit.config.ts tests/compose.test.ts
docker compose config --quiet # main
docker compose --project-directory . -f docker/compose/services.yml config --quiet # and the other three