OmniRoute Docker & Self-Hosted Deployment

GitHub

Never stop coding. Free MIT AI gateway: one endpoint, 330+ providers (90+ free), 1200+ models โ€” Kimi, Claude, GPT, Gemini, GLM, DeepSeek, MiniMax.

46,673 stars TypeScript TypeScript 1 file ยท ~16,425 tokens #a2a#ai-agents#ai-gateway#anthropic#claude#claude-code#cline#codex
RAW Compose
1. Pull Image (DOCKERHUB) docker pull library/omniroute
2. Docker Run (1-Click Launch) docker run -d --name omniroute -p 6379:6379 --restart always omniroute:base
3. Podman Run podman run -d --name omniroute -p 6379:6379 --restart always omniroute:base
4. Skopeo Inspect (Zero-Pull Manifest) skopeo inspect docker://docker.io/library/omniroute:base
5. ORAS Pull (OCI Artifact) oras pull omniroute:base
6. Compose Deployment docker compose up -d
docker-compose.yml
Download .yml
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
#  OmniRoute โ€” Docker Compose
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
#
#  Profiles:
#    base  โ†’ minimal image, no CLI tools
#    web   โ†’ runner-web (+Chromium/Playwright) for web-cookie providers
#    cli   โ†’ bundled CLIs installed inside the container (portable)
#    host  โ†’ runner-base + host-mounted CLI binaries (Linux-first; Linux only)
#    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
#    # Windows Docker Desktop: use the bundled CLI image and rebuild it
#    docker compose --profile cli up -d --build
#    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
#
#  Docker Compose uses `--profile <name> <command>`; `load profile` is not a
#  valid command. The `base` profile has no CLI tools, and `host` is Linux-only.
#  Windows Docker Desktop users who need the four bundled CLI packages should use `cli`.
#
#  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
      - CDP_PROXY_TOKEN=${CDP_PROXY_TOKEN:-}
      - CDP_PROXY_TOKEN_FILE=/cdp-token/token
    volumes:
      - chatgpt-web-codex-cdp-token:/cdp-token:ro
    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}"
    # SECURITY (#13679): joins BOTH `default` (to keep reaching redis and the
    # other sidecars) AND the dedicated `chatgpt-web-codex-net` (the one
    # legitimate consumer of the CDP proxy below).
    networks:
      - default
      - chatgpt-web-codex-net
    profiles:
      - web

  # Internal-only Chromium runtime for ChatGPT Web (Codex). No CDP or browser
  # UI port is published to the host.
  #
  # SECURITY (#13679): isolated onto its own `chatgpt-web-codex-net` network
  # instead of the shared implicit default bridge โ€” its cdp-proxy.mjs
  # sidecar republishes Chromium's CDP on 0.0.0.0:9223, and CDP grants full
  # control over a live browser session. Without this isolation, any
  # compromised sibling container (redis, qdrant, bifrost, cliproxyapi,
  # codex-app-server, ...) on the default network could reach it.
  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"
    environment:
      # #14486: fail-closed. Empty CDP_PROXY_TOKEN => the proxy generates a
      # secret into the shared cdp-token volume that omniroute-web reads.
      - CDP_PROXY_TOKEN=${CDP_PROXY_TOKEN:-}
      - CDP_PROXY_TOKEN_FILE=/cdp-token/token
    volumes:
      - chatgpt-web-codex-browser-data:/browser-profile
      - chatgpt-web-codex-cdp-token:/cdp-token
    networks:
      - chatgpt-web-codex-net
    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

networks:
  # SECURITY (#13679): dedicated network for the unauthenticated-by-default
  # CDP proxy sidecar (docker/chatgpt-web-codex-browser/cdp-proxy.mjs) โ€”
  # shared only with omniroute-web, not with redis/qdrant/bifrost/cliproxyapi/
  # codex-app-server or any other sibling on the implicit default network.
  chatgpt-web-codex-net: {}

volumes:
  chatgpt-web-codex-cdp-token:
    name: omniroute-chatgpt-web-codex-cdp-token
  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