services: # The claude-agent image every Session (and every clone / login helper) runs. # This service exists ONLY to build that image: web-manager creates agent # containers as siblings through the socket proxy, so compose must never start # one itself — hence `replicas: 0`. It deliberately has NO profile: a profiled # service is skipped by `docker compose build`, which is what used to leave a # fresh deployment without the agent image unless someone ran the (now removed) # manual `--profile agent` flow. claude-agent: image: cc-remote-claude-agent build: . deploy: replicas: 0 # Never started by compose, but pinned to the agents net so nothing can ever # accidentally start it on the control net (where the socket proxy lives). networks: - cc-remote-agents docker-socket-proxy: # TODO: pin to a specific digest for reproducibility once vetted (tag pinned to v0.4.2 for now) image: tecnativa/docker-socket-proxy:v0.4.2 restart: unless-stopped environment: - CONTAINERS=1 - VOLUMES=1 - POST=1 # EXEC gates /exec/{id}/start|json — needed by the workspace git probe # (uncommitted-work guard before destroy/reset). Creating the exec is a # /containers route, but starting it is not; without this it 403s. - EXEC=1 - IMAGES=0 - NETWORKS=0 - BUILD=0 - INFO=0 volumes: - /var/run/docker.sock:/var/run/docker.sock:ro # No ports are published to the host, and it sits on the CONTROL network only: # web-manager can reach it (DOCKER_HOST=tcp://docker-socket-proxy:2375), agent # containers cannot. That is the whole trust boundary — see `networks:` below. networks: - cc-remote-control # web-manager: the TanStack Start + Nitro app (webapp/). ONE service, ONE # Caddy upstream — it serves static + SSR + API + SSE + WS on port 4000, so # Caddy needs no WebSocket stanza (it proxies upgrades by default). Runs # unprivileged: it reaches Docker only through docker-socket-proxy over TCP, # never a mounted socket. web-manager: # Build context is the repo root (monorepo): the pnpm workspace manifest and # the single lockfile live there; the Dockerfile stays under webapp/. build: context: . dockerfile: webapp/Dockerfile image: cc-remote-web-manager restart: unless-stopped depends_on: - docker-socket-proxy # Cap web-manager's own memory. Every agent container is capped # (AGENT_MEMORY_LIMIT), but web-manager was not: it is multi-homed onto the # agents net and runs the credential broker on 0.0.0.0:4001, so an untrusted # agent can drive traffic at it. A bounded process fails predictably (OOM, # restarted by the `unless-stopped` policy) instead of dragging the host down. mem_limit: 1g security_opt: - no-new-privileges:true ports: - "127.0.0.1:${WEB_PORT:-4000}:4000" environment: - PORT=4000 # Auth: base URL and stable signing secret. GitHub identity (OAuth client # credentials, App ID, private key, allow-list) is configured through the # browser bootstrap screen (Phase 2) and stored in the Bootstrap File on the # persisted data volume, not in compose env vars. - BETTER_AUTH_URL=${BETTER_AUTH_URL} - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET} # Docker adapter: reach the daemon via the proxy over the CONTROL net; # created sibling agent containers join the AGENTS net, which web-manager is # also on, so the WS proxy can dial their ttyd by name (:7681). - DOCKER_HOST=tcp://docker-socket-proxy:2375 - AGENT_IMAGE=cc-remote-claude-agent - AGENT_NETWORK=cc-remote-agents # Per-container caps for EVERY container the adapter creates (Sessions, clone # helpers, Login Containers, seed/probe helpers). setup.sh derives these from the # host's real RAM/CPU count — web-manager cannot (INFO=0 on the socket proxy, and # it stays that way) — and compiles them into .env. The fallbacks below only cover # a deployment that never reran setup.sh; they are deliberately a *limit*, not # "unlimited", because unbounded agents can OOM the host and kill web-manager with # it. An unparseable value makes web-manager refuse to start (validate:env). # Memory takes human units (2g / 512m); MemorySwap is pinned to it (no swap). - AGENT_MEMORY_LIMIT=${AGENT_MEMORY_LIMIT:-2g} - AGENT_CPU_LIMIT=${AGENT_CPU_LIMIT:-2} - AGENT_PIDS_LIMIT=${AGENT_PIDS_LIMIT:-4096} - PERMISSION_MODE=${PERMISSION_MODE:-auto} # No GIT_USER_NAME/GIT_USER_EMAIL: a Session's git author is the Commit # Identity of the signed-in user who provisions it, derived per-Session # from their GitHub profile. Reintroducing a deployment-wide value here # would only ever apply when the real identity is missing, which is # exactly when committing must fail instead of authoring as nobody. - PUID=${PUID:-1000} - PGID=${PGID:-1000} # SQLite (domain + better-auth tables) on a persisted named volume so # Accounts and auth sessions survive `docker compose down && up`. - DATABASE_PATH=/data/cc-remote.db # `debug` library namespaces (silent unless set). Dev: DEBUG=cc-remote:* - DEBUG=${DEBUG:-} volumes: - cc-remote-db:/data # Multi-homed on purpose: control (to talk to the socket proxy) + agents (to # dial each Session's ttyd). It is the ONLY thing bridging the two. networks: - cc-remote-control - cc-remote-agents caddy: image: caddy:2 restart: unless-stopped profiles: - caddy ports: - "${CADDY_HTTP_PORT_BIND:-80:80}" - "${CADDY_HTTPS_PORT:-443}:443" environment: - DOMAIN_NAME=${DOMAIN_NAME} volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - caddy_data:/data - caddy_config:/config depends_on: - web-manager networks: - cc-remote-control # TWO networks, and the split IS the security boundary. # # docker-socket-proxy exposes POST /containers/create and does NOT inspect request # bodies, so anything that can open a TCP connection to it can create a container # with Binds: ["/:/host"] — i.e. root on the host — and can read every other # container's env (GITHUB_TOKEN, ANTHROPIC_*) via GET /containers/*/json. Agent # containers run untrusted, AI-generated code under `--permission-mode auto`, so a # malicious dependency or a prompt injection would be one `curl` away from that. # They therefore must not share a network with the proxy. # # cc-remote-control docker-socket-proxy + web-manager + caddy # cc-remote-agents web-manager + every agent container (Sessions, clone helpers, # login containers, seed/probe helpers) # # web-manager is multi-homed and is the only bridge between them: it reaches the # proxy over control and each agent's ttyd over agents. From the agents net the # proxy neither resolves by name nor answers on its control-net IP. # # Names are set explicitly so they are deterministic (independent of the compose # project/dir name) — the Docker adapter attaches created sibling agent containers # by name via AGENT_NETWORK. # # Deliberately NO `com.docker.network.bridge.enable_icc=false` on the agents net: # on an ICC-off bridge Docker drops ALL container-to-container traffic (DNS still # resolves, connects hang/reset), which would kill web-manager -> ttyd as well, and # there is no way to exempt one container on that bridge. It would break the web # terminal without adding anything the split does not already give. Agent<->agent # traffic on the agents net therefore remains possible (see docs/security.md, S1). networks: cc-remote-control: name: cc-remote-control cc-remote-agents: name: cc-remote-agents volumes: caddy_data: caddy_config: # SQLite database + WAL sidecars (Accounts, settings, better-auth sessions). cc-remote-db: