services: claude: build: context: . # Image user must match the host user sessions exec as; set per host in # ~/.config/iso-claude/.env (e.g. ISO_UID=3000 on faber). Only the bin/ # scripts read that file, so build with `iso-compose build`; plain # `docker compose build` falls back to 1000 (the launcher refuses the image). args: USER_UID: ${ISO_UID:-1000} USER_GID: ${ISO_GID:-1000} image: iso-claude container_name: iso-claude init: true entrypoint: ["/usr/local/bin/entrypoint.sh"] command: ["sleep", "infinity"] # PID 1 (the entrypoint) MUST run as root so it can install the firewall. # Sessions get the host UID via `--user` on `docker compose exec` (see bin/). user: "0:0" volumes: # NOTE: mapping /opt/workspace -> /opt/workspace means tools like git # (which converts relative paths to absoluate, despite my efforts) work # both in, and outside of the docker; a useful property :) # Claude's state lives inside it too (CLAUDE_CONFIG_DIR below). - "${CLAUDE_WORK_DIR:-/opt/workspace}:/opt/workspace" # Shared toolchains (rust, uv, go, sccache): one root-owned copy for host and # guest at the identical path, read-only here so a session can't alter # binaries the host runs (tooling/README.md). Long form on purpose: on this # Docker the short "/opt/tooling:/opt/tooling:ro" mounts nothing when source # and target paths match. - type: bind source: /opt/tooling target: /opt/tooling read_only: true # Config the host loads (dotfiles, agent rules); read-only so a session # can't plant hooks or instructions that run outside the sandbox. Must be a # direct child of the workspace mount: a nested mount point is EBUSY to # rename, but a deeper one can be escaped by renaming its writable parent. - type: bind source: ${CLAUDE_WORK_DIR:-/opt/workspace}/config target: /opt/workspace/config read_only: true working_dir: /opt/workspace environment: # HOME is the iso-claude user's real home (image-baked dotfiles resolve # normally); Claude's state is redirected into the workspace mount. - HOME=/home/iso-claude # The container path of $CLAUDE_STATE_DIR, derived by bin/ (iso_load_settings). # `:?` makes plain `docker compose`, which skips the launcher settings, fail # rather than start the container with the wrong config. - CLAUDE_CONFIG_DIR=${ISO_CLAUDE_CONFIG_DIR:?run compose via bin/iso-compose} # The OAuth token (CLAUDE_CODE_OAUTH_TOKEN) is deliberately NOT declared here. # A compose-level passthrough becomes container config, and can force rebuilds. # Instead rely on per session wrappers using -e CLAUDE_CODE_OAUTH_TOKEN - TERM=${TERM} - COLORTERM=${COLORTERM:-} # Upstream resolvers Docker's embedded DNS forwards to; init-firewall.sh # allows port 53 to them (derived by bin/, see iso_load_settings). - ISO_DNS_UPSTREAM=${ISO_DNS_UPSTREAM:-} cap_drop: [ALL] cap_add: [NET_ADMIN, NET_RAW] security_opt: - no-new-privileges:true # Guardrails, not throttles — the intent is still to let sessions use # most/all of the box's resources. pids_limit only catches pathological # cases (fork bombs); no real build/test run spawns thousands of # concurrent processes. mem_limit is deliberately loose (small buffer # below total host RAM) rather than a real cap: without it, an OOM is # handled by the HOST kernel's OOM killer, which can pick anything on the # box (sshd, dockerd, iso-firewall's reconcile); with it, the container's # own cgroup OOM killer fires first and only kills processes inside the # sandbox. Kept loose because debug/ASan duckdb builds are known to be # memory-hungry at link time (mold was adopted specifically to make that # fit in RAM) — if a real build ever gets OOM-killed (exit 137) under this # cap, raise it further or drop it rather than assuming the build is at fault. # Sized for a 32G host. The buffer also has to cover the ZFS ARC, which # grows to fill what is left — lower this, not the ARC target, if they fight. pids_limit: 4096 mem_limit: 28g restart: unless-stopped