Run Claude Code in a Docker sandbox, one container per project
On this page
What a container protects, and what it does notBuild an imageRun one container per projectLimiting the networkTrapsHow Codeman handles itBuild an image that contains the CLI, then run one long-lived container per project as your own non-root user, with the project bind-mounted at the same path, capabilities dropped, and a per-project home directory that keeps the CLI's login. The non-root user is not optional: Claude Code refuses --dangerously-skip-permissions when it runs as root.
What a container protects, and what it does not
Commands the agent runs execute inside the container, so a stray rm -rf or a hostile install script hits the container's filesystem instead of your laptop's. Your project is still bind-mounted, so every edit lands directly in your real repository.
Anthropic's dev container docs are blunt about the rest: with --dangerously-skip-permissions, a container does not stop a malicious project from exfiltrating anything reachable inside it, including the Claude Code credentials in ~/.claude. Use it with repositories you trust, and do not mount ~/.ssh or cloud credential files into it.
Build an image
FROM node:22-bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends git tmux ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g @anthropic-ai/claude-code
docker build -t claude-agent .
Add whatever your project needs to build and test, since the agent will run those commands inside this image. Other agent CLIs install the same way; add them to the same image or build one per CLI.
Run one container per project
mkdir -p ~/.agent-homes/myapp
docker run -d --name claude-myapp \
--user "$(id -u):$(id -g)" \
--cap-drop ALL --security-opt no-new-privileges \
--pids-limit 512 --memory 4g --memory-swap 4g --init \
-e HOME=/home/agent \
-v ~/.agent-homes/myapp:/home/agent \
-v "$PWD":"$PWD" -w "$PWD" \
claude-agent sleep infinity
docker exec -it claude-myapp tmux new -A -s main claude
What each part is for:
--user "$(id -u):$(id -g)"runs as your host user. Files the agent creates in the project stay owned by you, and Claude Code accepts--dangerously-skip-permissions. As root it exits with--dangerously-skip-permissions cannot be used with root/sudo privileges for security reasons.--cap-drop ALLandno-new-privilegesremove the kernel capabilities a container normally gets and block privilege escalation through setuid binaries.--memoryequal to--memory-swapcaps memory with no swap on top, per Docker's docs.--pids-limitcaps how many processes the container can run.-v "$PWD":"$PWD" -w "$PWD"mounts the project at the same absolute path it has on the host, so any path the agent prints is valid on your machine too.- The home directory mount keeps the CLI's login and settings per project. Claude Code keeps its login in both
~/.claudeand~/.claude.json, so mounting a whole home directory covers both. Runclaudeonce inside and log in. sleep infinityplusdocker execkeeps the container alive between sessions.tmux new -A -s mainattaches to the existing tmux session if there is one, so you can detach withCtrl-b dand come back later.
Never add --privileged or mount /var/run/docker.sock. Either one hands the agent the host.
Limiting the network
--network none looks tempting, but it also cuts the agent off from its model API, so the CLI cannot work. The practical option is an egress allowlist. Anthropic's reference dev container ships an init-firewall.sh that limits outbound traffic to the destinations it allows; running a firewall inside the container needs the NET_ADMIN and NET_RAW capabilities, which you would add back after --cap-drop ALL. You can also leave the container alone and enforce egress rules on the host or network instead.
Traps
- Named volumes as the home directory. A fresh named volume takes the ownership of the image directory it is mounted over, or root if that directory does not exist, so a container running as your user often cannot write to it. A host directory you created yourself, as above, avoids this.
- Stale CLI versions after a rebuild.
docker buildreuses the cachednpm install -glayer, so a rebuild keeps whatever version the layer first installed. Build with--no-cachewhen you want new versions. - Other CLIs keep state in their own directories. Codex uses
~/.codex(or$CODEX_HOME); Antigravity keeps its state under~/.gemini. With the whole home directory mounted per project, each CLI's login persists without extra mounts. - One container per project, not one per session. Several sessions for the same project can
docker execinto the same container, which keeps installed packages and build caches shared.
How Codeman handles it
Codeman builds this pattern in as Docker cases. On Add Case → Create New, tick 🐳 Run in an isolated Docker container; expanding the container settings offers Small, Medium, Large and GPU templates for memory and CPU.
What it does for you:
- One long-lived container per case, shared by every session in it. Killing a session kills only that session's tmux inside the container. After a container stop or host reboot, the container restarts and the last conversation resumes from the bind-mounted transcript.
- The same hardening as above:
--cap-drop ALL,no-new-privileges, your host uid, a PID limit, a memory cap with swap pinned to it and--init. Never--privilegedand never the Docker socket. - The workspace at the same absolute path, so the file viewer and attachments work on the real host files.
- Credentials seeded, not shared: your host logins are mounted read-only and copied in at launch, so you do not log in again, and refreshed tokens are never written back to your host credential stores. Seeding can be switched off for untrusted work.
- Any CLI inside: Claude Code, Codex, Gemini CLI, OpenCode and the other run modes all work in a container.
- Drift detection: changing the image, memory or network after the container exists refuses the launch and offers to recreate it, instead of silently running the old configuration.
- Moving a case: export the image and workspace as one bundle and import it on another machine.
A container you already run can be attached instead, with Attach to an existing container; Codeman then only execs into it and never starts, stops or removes it.
One setting to know: in-container Claude hooks call back to the Codeman server, and with Codeman's default loopback bind they cannot reach it. Set CODEMAN_DOCKER_BRIDGE_HOOKS=1 to open a hooks-only listener on the Docker bridge. The full reference is Docker Cases, and where containers fit in the threat model is covered in Security.