Files
OmniRoute/docker-compose.yml
Diego Rodrigues de Sa e Souza 57a37ed858 fix(docker): bind app/sidecar compose ports to loopback by default (#12568, #12578) (#13240)
Merged as part of the 39-PR owner batch of 2026-09-11, validated as a unit.

Boarded into one consolidated worktree cut from `release/v3.8.51` with the other 38 — zero conflicts between them.

- ESLint over every changed file: no errors (the only finding was one suppression entry the batch emptied, pruned on #13243)
- `typecheck:core` clean; `check:dashboard-typecheck` OK (206 pre-existing, within baseline); `check:changelog-integrity` OK
- complexity 2821 / baseline 3218 and cognitive-complexity 1272 / baseline 1437 — both under baseline
- 256 assertions green: 246 under node:test and 10 under vitest, which is where `tests/unit/**/*.test.tsx` actually runs
- `check-file-size`: `chatCore.ts` rebaselined 6144 → 6146 for #13278 and #13276, annotated and landed on #13243

⚠️ base-red inherited: #12732 — the provider count (356 in the docs vs the 358 the modules define) and `open-sse/utils/stream.ts` at 3115 > frozen 3098 both reproduce on the pure tip with zero contribution from this batch.
2026-09-11 22:05:14 -03:00

396 lines
17 KiB
YAML

# ──────────────────────────────────────────────────────────────────────
# OmniRoute — Docker Compose
# ──────────────────────────────────────────────────────────────────────
#
# Profiles:
# base → minimal image, no CLI tools
# web → runner-web (+Chromium/Playwright) for web-cookie providers
# cli → CLIs installed inside the container (portable)
# host → runner-base + host-mounted CLI binaries (Linux-first)
# cliproxyapi → CLIProxyAPI sidecar on port 8317
# memory → Qdrant sidecar on port 6333 (semantic memory offload)
# bifrost → Bifrost Go sidecar on port 8080 (Tier-1 LLM router)
#
# Usage:
# docker compose --profile base up -d
# docker compose --profile web up -d # gemini-web / claude-web / claude-turnstile
# docker compose --profile cli up -d
# docker compose --profile host up -d
# docker compose --profile cliproxyapi up -d
# docker compose --profile cli --profile cliproxyapi up -d
# docker compose --profile base --profile memory up -d # adds Qdrant sidecar
# docker compose --profile base --profile bifrost up -d # adds Bifrost sidecar
#
# See docs/architecture/cluster-decisions.md for the per-component rationale
# (Qdrant=opt-in, Bifrost=opt-in, Caddy=upstream LB, no Dragonfly/NATS/PG/Neo4j/MinIO).
#
# Before first run, copy .env.example → .env and edit your secrets.
# ──────────────────────────────────────────────────────────────────────
x-common: &common
restart: unless-stopped
stop_grace_period: 40s
env_file: .env
environment:
- DATA_DIR=/app/data # Must match the volume mount below
- OMNIROUTE_BASE_PATH=${OMNIROUTE_BASE_PATH:-}
- PORT=${PORT:-20128}
- DASHBOARD_PORT=${DASHBOARD_PORT:-20128}
- API_PORT=${API_PORT:-20129}
- API_HOST=${API_HOST:-127.0.0.1}
- LIVE_WS_PORT=${LIVE_WS_PORT:-20132}
- LIVE_WS_HOST=${LIVE_WS_HOST:-127.0.0.1}
- LIVE_WS_ALLOWED_ORIGINS=${LIVE_WS_ALLOWED_ORIGINS:-http://localhost:20128,http://127.0.0.1:20128}
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
- NODE_OPTIONS=--max-old-space-size=2048
# Codex App-Server transport (provider: codex-app-server). Inert unless the
# `codex-app-server` compose profile is up (the sidecar below). Points the app
# at the internal sidecar; the capability token is shared via the mounted file.
- OMNIROUTE_CODEX_APPSERVER_WS=${OMNIROUTE_CODEX_APPSERVER_WS:-ws://codex-app-server:1456}
- OMNIROUTE_CODEX_APPSERVER_WS_TOKEN_FILE=${OMNIROUTE_CODEX_APPSERVER_WS_TOKEN_FILE:-/run/codex-appserver/token}
volumes:
- ./data:/app/data
# Shared capability token + codex auth for the app-server WS. Only meaningful
# when the codex-app-server profile is active. The token dir carries the WS
# capability token; the codex home is where the dashboard "Apply auth" writes
# ~/.codex/auth.json (getCliConfigPaths("codex") = <home>/.codex; the base
# image runs as `node`, so /home/node/.codex) and the SAME volume is mounted
# into the sidecar so its `codex app-server` reads the same auth.
- codex-appserver-token:/run/codex-appserver
- codex-appserver-home:/home/node/.codex
healthcheck:
test: ["CMD", "node", "healthcheck.mjs"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
services:
# ── Redis (Rate Limiter Backend) ──────────────────────────────────
redis:
image: docker.io/library/redis:8.6.5-alpine
container_name: omniroute-redis
restart: unless-stopped
ports:
# Loopback-only by default: this Redis has no `requirepass`, and the app
# containers reach it over the compose network (redis:6379), so the
# published port exists purely for host-side tooling (redis-cli, a local
# `npm run dev`). A bare "6379:6379" binds 0.0.0.0 — that puts an
# unauthenticated Redis on every LAN interface. Override REDIS_BIND_HOST
# only together with a password (`--requirepass`).
- "${REDIS_BIND_HOST:-127.0.0.1}:${REDIS_PORT:-6379}:6379"
volumes:
- redis-data:/data
command: redis-server --save 60 1 --loglevel warning
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3
# ── Profile: base (minimal, no CLI tools) ──────────────────────────
omniroute-base:
<<: *common
container_name: omniroute
build:
context: .
target: runner-base
args:
OMNIROUTE_BASE_PATH: ${OMNIROUTE_BASE_PATH:-}
image: omniroute:base
ports:
# Loopback-only by default (#12568): with REQUIRE_API_KEY=false shipping
# as the .env.example default, an unqualified publish spec here binds
# 0.0.0.0 and exposes the anonymous /v1 LLM proxy on every LAN/WAN
# interface. Set APP_BIND_HOST=0.0.0.0 only once you've confirmed
# REQUIRE_API_KEY=true or an upstream reverse proxy enforces its own auth.
- "${APP_BIND_HOST:-127.0.0.1}:${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}"
- "${APP_BIND_HOST:-127.0.0.1}:${API_PORT:-20129}:${API_PORT:-20129}"
- "${APP_BIND_HOST:-127.0.0.1}:${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}"
profiles:
- base
# ── Profile: web (runner-web + Chromium/Playwright) ────────────────
# Required for web-cookie providers (gemini-web, claude-web, claude-turnstile).
# The default `base` image ships without Chromium, so those providers fail
# with "Executable doesn't exist at .../ms-playwright/chromium..." (#2832).
omniroute-web:
<<: *common
container_name: omniroute
build:
context: .
target: runner-web
args:
OMNIROUTE_BASE_PATH: ${OMNIROUTE_BASE_PATH:-}
image: omniroute:web
depends_on:
chatgpt-web-codex-browser:
condition: service_started
environment:
- DATA_DIR=/app/data
- PORT=${PORT:-20128}
- DASHBOARD_PORT=${DASHBOARD_PORT:-20128}
- API_PORT=${API_PORT:-20129}
- API_HOST=${API_HOST:-127.0.0.1}
- LIVE_WS_PORT=${LIVE_WS_PORT:-20132}
- LIVE_WS_HOST=${LIVE_WS_HOST:-127.0.0.1}
- LIVE_WS_ALLOWED_ORIGINS=${LIVE_WS_ALLOWED_ORIGINS:-http://localhost:20128,http://127.0.0.1:20128}
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
- OMNIROUTE_BASE_PATH=${OMNIROUTE_BASE_PATH:-}
- CHATGPT_WEB_CODEX_CDP_URL=http://chatgpt-web-codex-browser:9223
ports:
- "${APP_BIND_HOST:-127.0.0.1}:${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}"
- "${APP_BIND_HOST:-127.0.0.1}:${API_PORT:-20129}:${API_PORT:-20129}"
- "${APP_BIND_HOST:-127.0.0.1}:${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}"
profiles:
- web
# Internal-only Chromium runtime for ChatGPT Web (Codex). No CDP or browser
# UI port is published to the host.
chatgpt-web-codex-browser:
build:
context: .
dockerfile: docker/chatgpt-web-codex-browser/Dockerfile
image: omniroute:chatgpt-web-codex-browser
restart: unless-stopped
shm_size: "2gb"
volumes:
- chatgpt-web-codex-browser-data:/browser-profile
profiles:
- web
# ── Profile: cli (CLIs installed inside container) ─────────────────
omniroute-cli:
<<: *common
container_name: omniroute
build:
context: .
target: runner-cli
args:
OMNIROUTE_BASE_PATH: ${OMNIROUTE_BASE_PATH:-}
image: omniroute:cli
ports:
- "${APP_BIND_HOST:-127.0.0.1}:${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}"
- "${APP_BIND_HOST:-127.0.0.1}:${API_PORT:-20129}:${API_PORT:-20129}"
- "${APP_BIND_HOST:-127.0.0.1}:${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}"
volumes:
- ./data:/app/data
# SECURITY: mounting the host Docker socket gives this container full
# control over the host Docker daemon — it can create/list/stop/rm any
# container the host runs. It is here so the in-container auto-updater
# (src/lib/system/autoUpdate.ts) can recreate the stack. Only use this
# profile on a single-tenant workstation you trust, and never publish
# its ports beyond 127.0.0.1. See docs/guides/DOCKER_GUIDE.md →
# "Escape hatch: configure the container's own CLIs" for the threat model.
- /var/run/docker.sock:/var/run/docker.sock
- /usr/libexec/docker/cli-plugins:/usr/libexec/docker/cli-plugins:ro
- ${AUTO_UPDATE_HOST_REPO_DIR:-.}:/workspace/omniroute:rw
profiles:
- cli
# ── Profile: host (host-mounted CLI binaries, Linux-first) ────────
omniroute-host:
<<: *common
container_name: omniroute
build:
context: .
target: runner-base
args:
OMNIROUTE_BASE_PATH: ${OMNIROUTE_BASE_PATH:-}
image: omniroute:base
ports:
- "${APP_BIND_HOST:-127.0.0.1}:${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}"
- "${APP_BIND_HOST:-127.0.0.1}:${API_PORT:-20129}:${API_PORT:-20129}"
- "${APP_BIND_HOST:-127.0.0.1}:${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}"
environment:
- DATA_DIR=/app/data
- PORT=${PORT:-20128}
- DASHBOARD_PORT=${DASHBOARD_PORT:-20128}
- API_PORT=${API_PORT:-20129}
- API_HOST=${API_HOST:-127.0.0.1}
- LIVE_WS_PORT=${LIVE_WS_PORT:-20132}
- LIVE_WS_HOST=${LIVE_WS_HOST:-127.0.0.1}
- LIVE_WS_ALLOWED_ORIGINS=${LIVE_WS_ALLOWED_ORIGINS:-http://localhost:20128,http://127.0.0.1:20128}
- CLI_MODE=host
- CLI_EXTRA_PATHS=/host-local/bin:/host-node/bin
- CLI_CONFIG_HOME=/host-home
- CLI_ALLOW_CONFIG_WRITES=true
# Uncomment per-tool overrides as needed:
# - CLI_CURSOR_BIN=agent
# - CLI_CLINE_BIN=cline
# - CLI_CONTINUE_BIN=cn
volumes:
- ./data:/app/data
# ── Host binary mounts (read-only) ──
# Adjust paths below to match YOUR host system.
- ~/.local/bin:/host-local/bin:ro
# Node global binaries (adjust node version path)
# - ~/.nvm/versions/node/v24.14.1/bin:/host-node/bin:ro
# ── Host config mounts (read-write) ──
- ~/.codex:/host-home/.codex:rw
- ~/.claude:/host-home/.claude:rw
- ~/.factory:/host-home/.factory:rw
- ~/.openclaw:/host-home/.openclaw:rw
- ~/.cursor:/host-home/.cursor:rw
- ~/.config/cursor:/host-home/.config/cursor:rw
profiles:
- host
# ── Profile: memory (Qdrant semantic-memory sidecar) ─────────────
# Off by default. SQLite + sqlite-vec + FTS5 (RRF) is the primary vector
# store (see src/lib/memory/vectorStore.ts). Enable only when you need
# cross-instance memory sharing or >1M points — at which point the
# QDRANT_ENABLED=true flag activates the dual-write path in
# src/lib/memory/qdrant.ts:37. See docs/architecture/cluster-decisions.md
# for the workload analysis behind this profile.
qdrant:
image: docker.io/qdrant/qdrant:v1.12.4
container_name: omniroute-qdrant
restart: unless-stopped
ports:
- "${QDRANT_BIND_HOST:-127.0.0.1}:${QDRANT_PORT:-6333}:6333"
- "${QDRANT_BIND_HOST:-127.0.0.1}:${QDRANT_GRPC_PORT:-6334}:6334"
volumes:
- qdrant-data:/qdrant/storage
environment:
- QDRANT__SERVICE__GRPC_PORT=6334
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:6333/readyz"]
interval: 15s
timeout: 5s
retries: 3
start_period: 10s
profiles:
- memory
# ── Profile: bifrost (Bifrost Go LLM-router sidecar) ─────────────
# Off by default. Bifrost is the Tier-1 LLM router (per ADR-031 and
# open-sse/executors/bifrost.ts). When BIFROST_ENABLED=true is set in
# .env, OmniRoute delegates /v1/chat/completions, /v1/responses, and
# /v1/embeddings to this sidecar instead of handling them in chatCore.
# The kill switch is the BIFROST_ENABLED env var — flip to false to
# fall back to the chatCore path with zero code changes. See
# docs/architecture/cluster-decisions.md for the activation plan.
bifrost:
image: ghcr.io/maximhq/bifrost:v1.6.11
container_name: omniroute-bifrost
restart: unless-stopped
ports:
- "${BIFROST_BIND_HOST:-127.0.0.1}:${BIFROST_PORT:-8080}:8080"
volumes:
- bifrost-data:/data
environment:
- BIFROST_LOG_LEVEL=${BIFROST_LOG_LEVEL:-info}
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:8080/v1/models"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
profiles:
- bifrost
# ── Profile: cliproxyapi (CLIProxyAPI as sidecar) ─────────────────
# Official pre-built image lives on Docker Hub (eceasy/cli-proxy-api);
# ghcr.io/router-for-me/* is not publicly pullable. v6.9.7 is the pinned
# version the sidecar integration (port 8317, /v1/models healthcheck) targets.
cliproxyapi:
container_name: cliproxyapi
image: docker.io/eceasy/cli-proxy-api:v6.9.7
restart: unless-stopped
ports:
# Loopback-only by default: this sidecar's data volume
# (cliproxiapi-data:/root/.cli-proxy-api) holds provider OAuth/API
# credentials, and the pinned image only reads api-keys from a mounted
# config.yaml (not env vars), so an unqualified "8317:8317" publish spec
# would put a credential-bearing service with no compose-configured
# data-plane auth on every LAN interface. Same reasoning as Redis above.
- "${CLIPROXY_BIND_HOST:-127.0.0.1}:${CLIPROXYAPI_PORT:-8317}:${CLIPROXYAPI_PORT:-8317}"
volumes:
- cliproxyapi-data:/root/.cli-proxy-api
environment:
- PORT=${CLIPROXYAPI_PORT:-8317}
- HOST=0.0.0.0
# Forwards to the one auth-related env var the pinned binary actually
# reads (MANAGEMENT_PASSWORD) — secures the management API only; the
# data-plane completions endpoints have no env-based override upstream.
- MANAGEMENT_PASSWORD=${CLIPROXYAPI_MANAGEMENT_KEY:-}
healthcheck:
test:
["CMD", "wget", "--spider", "-q", "http://127.0.0.1:${CLIPROXYAPI_PORT:-8317}/v1/models"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
profiles:
- cliproxyapi
# ── Profile: codex-app-server (Codex CLI app-server sidecar) ──────────
# A PLAIN Codex app-server for the `codex-app-server` provider: OmniRoute drives
# the Codex CLI's own `codex app-server` over JSON-RPC/WebSocket instead of
# replaying a session token to the API. It listens ONLY on the internal compose
# network (ws://codex-app-server:1456), guarded by a capability token — it is
# NEVER published to the host / internet. The Codex CLI (baked into
# omniroute:base) self-manages its OpenAI OAuth via the shared ~/.codex volume,
# which the dashboard "Apply auth" (device-OAuth) writes and this sidecar reads.
#
# NOTE: this is the GENERIC public sidecar. An operator wanting residential /
# UDP egress (via a TUN sidecar) runs that separately as an override; it is
# intentionally not shipped here.
codex-app-server:
image: omniroute:base
container_name: omniroute-codex-app-server
restart: unless-stopped
# Generate the WS capability token on first boot if absent, then run the
# app-server. entrypoint is overridden because the base image's default is the
# Next.js server.
entrypoint: ["/bin/sh", "-c"]
command:
- |
set -e
TOKEN_FILE=/run/codex-appserver/token
mkdir -p /run/codex-appserver
if [ ! -s "$$TOKEN_FILE" ]; then
# 32-byte hex capability token; shared with the app via the token volume.
TF="$$TOKEN_FILE" node -e 'require("fs").writeFileSync(process.env.TF, require("crypto").randomBytes(32).toString("hex"))' 2>/dev/null || \
{ head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' > "$$TOKEN_FILE"; }
chmod 600 "$$TOKEN_FILE"
fi
exec codex app-server \
--listen ws://0.0.0.0:1456 \
--ws-auth capability-token \
--ws-token-file "$$TOKEN_FILE"
environment:
- CODEX_HOME=/home/node/.codex
- RUST_LOG=${CODEX_APPSERVER_RUST_LOG:-warn}
volumes:
- codex-appserver-token:/run/codex-appserver
- codex-appserver-home:/home/node/.codex
# No `ports:` — internal-only. Reached at ws://codex-app-server:1456 over the
# compose network by the omniroute app.
healthcheck:
test:
["CMD", "node", "-e", "require('http').get('http://127.0.0.1:1456/readyz',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
profiles:
- codex-app-server
volumes:
chatgpt-web-codex-browser-data:
name: omniroute-chatgpt-web-codex-browser-data
cliproxyapi-data:
name: cliproxyapi-data
redis-data:
name: omniroute-redis-data
qdrant-data:
name: omniroute-qdrant-data
bifrost-data:
name: omniroute-bifrost-data
codex-appserver-token:
name: omniroute-codex-appserver-token
codex-appserver-home:
name: omniroute-codex-appserver-home