Run Claude Code in an isolated Docker container instead of directly on your host. The container mirrors your host environment (same paths, UID, shell) so Claude's file references, git configs, and auto-memory all work seamlessly. Works on both macOS and Linux.
This is an opinionated project tuned for the way I and my colleagues work day to day. The goal is to keep the daily Claude-Code flow feeling exactly like running claude on the host — same paths, same git, same docker compose against your project's stack — while putting a soft blanket between Claude and the parts of your machine you'd rather it not touch by accident.
This is a safety net for bad prompts, not bad actors.
The threat model this project addresses is AI mistakes — the kind of footguns an LLM can stumble into when interpreting an ambiguous instruction, getting confused about state, or going overboard trying to be helpful. Concretely:
- You're checked out on
masterwithout realizing it and prompt "do the changes and push them". Claude triesgit push, the wrapper refuses pushes to protected branches, and you (the human) decide whether to push from the host. No accidental force-push tomasterbecause Claude didn't pause to ask. - You write "make REALLY sure that directory is gone" and Claude, in its enthusiasm, reaches for
sudo rm -rf /. The container is the blast radius — only your mounted project dirs are reachable, the host is untouched. Worst case you lose what you mounted, not your home directory. - A misread file path or runaway loop tries to write somewhere it shouldn't. The bind-mount allowlist confines damage to directories you explicitly opted in.
The threat model this project does not address is a deliberately adversarial Claude — an LLM actively crafting multi-step attacks to break out of the sandbox, exfiltrate credentials via sibling containers, or otherwise behave like a hostile insider. If that's your threat model, this isn't the right tool: don't give Claude the Docker socket at all, don't bind-mount ~/.claude, and consider air-gapped execution.
The friction trade-off goes one way on purpose: the sandbox must not get in our way. Standard docker compose up, docker compose exec, debugger attach, language servers, and the rest of the daily-driver workflow all work without per-project allowlists or extra config. If a hardening proposal would block a legitimate developer flow, it's out of scope for this project — even if it would close a theoretical attack path.
In short: paranoia calibrated to "AI mental breakdown", not to "nation-state in your chat window".
claude-docker is one of several sibling projects that apply the same sandboxing model to different AI coding agents. They share the security philosophy (filtered Docker socket, path mirroring, git-push wrapper, scope above) and most of the implementation, but each is adapted to its agent's config and auth model.
| Project | Wraps |
|---|---|
claude-docker (this project) |
Claude Code |
codex-docker |
OpenAI Codex CLI |
pi-docker |
pi |
Pick by which agent you actually use day to day. Running more than one in parallel is fine — the containers are independent.
- Isolated execution — Claude runs in an Alpine container, can't touch your host directly
- Docker socket proxy — filtered API access via wollomatic/socket-proxy, prevents container escape
- Path mirroring —
~/projectinside the container = same path on the host (works with both/Users/and/home/) - VSCode integration — works with the Claude Code extension via process wrapper
- Session teardown for terminal and IDE callers — host watchdog plus in-container wrapper clean up orphaned Claude processes even when the parent wrapper dies early
- GPG commit signing — import keys into the container for signed commits
- AWS credentials proxy — read-only AWS SSO credentials via host-side proxy (no secrets in the container)
- Pluggable notifications — customizable
claude-notifierscript for sound/alert integration
- macOS (Apple Silicon or Intel) or Linux (amd64/arm64)
- Docker Desktop, OrbStack (macOS), or Docker Engine (Linux)
- Claude Max subscription (authenticated via
claudeCLI on host) - Go (for building the AWS credential proxy and beep server; optional)
git clone https://github.com/hrubymar10/claude-docker.git
cd claude-docker# Add to your shell profile (~/.zshrc, ~/.bashrc, etc.)
export PATH="/path/to/claude-docker/bin:$PATH"This makes claude-docker-ctrl, claude-docker, and the VSCode wrapper available globally.
Claude Code stores its config in ~/.claude.json. Docker file-level bind mounts break on atomic writes (moby/moby#6011), causing config corruption on Docker Desktop. Setting CLAUDE_CONFIG_DIR moves the config file inside ~/.claude/, which is mounted as a directory — immune to this issue.
First, quit all running Claude Code instances (VSCode, terminal, JetBrains).
Then migrate the config file and set the env var:
# Move config into the directory (skip this if ~/.claude.json doesn't exist yet)
mv ~/.claude.json ~/.claude/.claude.json
# Add to your shell profile (~/.zshrc, ~/.bashrc, etc.)
export CLAUDE_CONFIG_DIR="$HOME/.claude"Restart your shell (or source the profile) before proceeding. claude-docker-ctrl will refuse to start without this variable set.
If you haven't already, install and authenticate the Claude CLI on your host:
curl -fsSL https://claude.ai/install.sh | bash
claude
# Follow the authentication flowThis creates ~/.claude/ which is mounted into the container.
cp config/docker-compose.local.example.yml config/docker-compose.local.ymlEdit config/docker-compose.local.yml to mount your project directories:
services:
claude:
volumes:
- ${HOST_HOME}/projects:${HOST_HOME}/projects
- ${HOST_HOME}/work:${HOST_HOME}/workHOST_HOME is auto-detected from your $HOME — no need to set it manually. Paths are mirrored (same path inside and outside the container).
bin/claude-docker-ctrl startThis auto-detects your username, UID, Go version, git identity, and GitHub token. No manual .env file needed (see config/.env.example if you want to override anything).
Terminal:
cd ~/projects/my-app
bin/claude-docker-ctrl execVSCode:
Add to VSCode settings.json — claudeProcessWrapper requires an absolute path, so point it at the wrapper inside the repo:
{
"claudeCode.claudeProcessWrapper": "/path/to/claude-docker/bin/claude-docker-vscode-wrapper",
"claudeCode.useTerminal": false,
"claudeCode.allowDangerouslySkipPermissions": true
}Important:
useTerminalmust befalse. Whentrue, the wrapper is bypassed and Claude runs on the host.Why
allowDangerouslySkipPermissions: true? Claude is already running inside the sandbox: the filesystem is limited to your mounted project dirs, the Docker socket is filtered, andgit pushto protected branches is blocked at the wrapper. The container is the safety net, so the per-action permission prompts mostly add friction without adding protection. Leave it off if you mount sensitive directories you don't fully trust Claude with.
bin/claude-docker-ctrl start # build image, start container
bin/claude-docker-ctrl stop # stop container
bin/claude-docker-ctrl status # show container status
bin/claude-docker-ctrl shell # shell into the container (auto-detects from host $SHELL)
bin/claude-docker-ctrl exec # interactive Claude session in container
bin/claude-docker-ctrl rebuild # rebuild image from scratch + restart-
Export your private key without passphrase into the
gpg-keys/directory:# Use a temp keyring to strip the passphrase without affecting your real keyring export GNUPGHOME=$(mktemp -d) gpg --batch --import <(gpg --export-secret-keys --armor YOUR_KEY_ID) gpg --pinentry-mode loopback --edit-key YOUR_KEY_ID passwd save gpg --export-secret-keys --armor YOUR_KEY_ID > /path/to/claude-docker/gpg-keys/signing.asc rm -rf "$GNUPGHOME" unset GNUPGHOME
-
The entrypoint auto-imports all
.asc/.gpgfiles at startup -
Configure signing per repo:
git config commit.gpgsign true && git config user.signingkey YOUR_KEY_ID
The gpg-keys/ directory is gitignored — keys never get committed.
Claude inside the container can use read-only AWS credentials via a host-side proxy. No AWS credentials are stored in the container — every request is forwarded to the host, which uses its active SSO session.
A small Go HTTP server (aws-cred-proxy/) runs on the host and serves credentials for explicitly allowlisted profiles only. Inside the container, ~/.aws/config is auto-generated with credential_process entries that curl the proxy via host.docker.internal.
-
Configure AWS SSO profiles in
~/.aws/configon the host (e.g. withViewOnlyAccesspermission set) -
Export the allowlist in your shell profile:
export AWS_CRED_PROXY_PROFILES="my-readonly:us-east-1,my-test-readonly:eu-west-1"
Format:
profile_name:regionpairs, comma-separated. Only listed profiles are served — all other requests return 403. -
Log in to SSO on the host:
aws sso login --profile my-readonly
-
Start (or restart) the container:
bin/claude-docker-ctrl start
The proxy builds and starts automatically. You can check its status with
bin/claude-docker-ctrl status.
aws s3 ls --profile my-readonly
aws s3 ls --profile my-test-readonlyWhen the SSO session expires (~12h), re-run aws sso login on the host — the proxy picks up the new session automatically, no container restart needed.
| Variable | Default | Description |
|---|---|---|
AWS_CRED_PROXY_PROFILES |
(unset) | Comma-separated name:region pairs. Proxy only starts when set. |
AWS_CRED_PROXY_PORT |
9998 |
Port for the credential proxy on the host. |
Claude inside the container can use your host SSH agent for git pushes, ssh connections, etc. — no private keys are copied into the container.
If SSH_AUTH_SOCK is set on the host (i.e. an ssh-agent is running), claude-docker-ctrl start automatically launches a socat TCP relay on 127.0.0.1:19922 that bridges the host's Unix-domain agent socket. Inside the container, SSH_AUTH_SOCK is configured to point at the relay over host.docker.internal, and ~/.ssh/known_hosts is bind-mounted in. Use ssh-add -l inside the container to confirm the agent is reachable.
- Make sure
socatis installed on the host. On macOS:brew install socat. On Linux: install via your package manager. - Make sure your SSH agent is running on the host (this is the default on most systems —
echo "$SSH_AUTH_SOCK"should print a path). - Add your key once:
ssh-add ~/.ssh/id_ed25519(or whichever key). - Start (or restart) the container:
bin/claude-docker-ctrl start.
ssh-add -l # list keys forwarded from the host agent
git push origin feature-x # uses the forwarded agentThe relay starts on claude-docker-ctrl start and stops on claude-docker-ctrl stop. Status is shown in claude-docker-ctrl status. If socat isn't available on the host, the relay is skipped with a warning and the rest of the container still starts normally.
| Variable | Default | Description |
|---|---|---|
SSH_AUTH_SOCK |
(host) | Read from your shell environment. If unset, no relay is started. |
A tiny host-side HTTP server that plays a sound when Claude pings it. Useful as a notification channel — e.g., have your claude-notifier hook fire curl http://host.docker.internal:9999/beep whenever Claude finishes a long-running task or hits a permission prompt.
-
Start the beeper:
bin/claude-docker-ctrl beeper-start
The Go binary is built on first run.
-
Stop it when you're done:
bin/claude-docker-ctrl beeper-stop
-
Wire your
claude-notifierscript (seeconfig/claude-notifier.example) to call the beeper. The container reaches the host athost.docker.internal:9999.
| Variable | Default | Description |
|---|---|---|
BEEPER_BIND |
127.0.0.1:9999 |
host:port to listen on. Host must be an IP literal (no hostnames). Set to 0.0.0.0:9999 to expose on all interfaces. |
BEEPER_ALLOW |
127.0.0.0/8 |
Comma-separated source IPs / CIDRs allowed to call the beeper. Bare IPs are normalised to /32 (v4) / /128 (v6). Anything else gets a 403. |
The defaults are sufficient for container-to-host traffic via host.docker.internal on Docker Desktop / OrbStack (it forwards to host loopback). For VPN clients or other remote callers, widen BEEPER_BIND and add the source range to BEEPER_ALLOW:
export BEEPER_BIND=0.0.0.0:9999
export BEEPER_ALLOW=127.0.0.0/8,172.28.47.0/24Linux note: on Linux Docker Engine, host.docker.internal resolves to the Docker bridge gateway (typically in 172.17.0.0/16 or 172.16.0.0/12), not host loopback. The default BEEPER_ALLOW=127.0.0.0/8 will block those requests. Add the bridge subnet to the allowlist. Note: beeper/main.go calls afplay (macOS only) — sound playback does not work on Linux.
X-Forwarded-For is not honoured — this is a direct-connection service.
┌─ Host (macOS / Linux) ──────────────────────────────────┐
│ │
│ VSCode ──► claude-docker-vscode-wrapper │
│ │ │
│ ▼ │
│ ┌─ Docker ────────────────────────────────────────┐ │
│ │ │ │
│ │ claude-docker claude-socket-proxy │ │
│ │ ┌──────────────┐ ┌──────────────────┐ │ │
│ │ │ Claude Code │ TCP │ wollomatic/ │ │ │
│ │ │ bash/zsh/fish,│─────►│ socket-proxy │ │ │
│ │ │ Node.js, git │ │ (API filtering) │ │ │
│ │ └──────────────┘ └────────┬─────────┘ │ │
│ │ │ │ │ │
│ │ │ bind mounts │ host socket │ │
│ └───────┼──────────────────────────┼───────────────┘ │
│ │ │ │
│ ~/projects /var/run/docker.sock │
│ ~/.claude/ │
└──────────────────────────────────────────────────────────┘
The container cannot access the host Docker socket directly. All Docker API calls go through a filtering proxy that:
- Only allows whitelisted API endpoints (regex-matched per HTTP method)
- Restricts bind mounts to directories actually mounted in the container
- Auto-derives the allowlist from your compose volume configuration
Additionally, the git binary is replaced with a wrapper that blocks pushes to protected branches (main, master by default) and any push that would publish tags (--tags, --follow-tags, --mirror, a refs/tags/* refspec, or the <remote> tag <name> shorthand). This is defense-in-depth — git-real is still callable directly by the unprivileged user, so the wrapper catches bad-prompt mistakes but won't defeat a deliberately adversarial Claude (see Scope). For real branch/tag protection, enforce it server-side. A docker wrapper blocks dangerous subcommands (run, build, cp).
See SECURITY_ISSUES.md for known limitations.
The container ships with language servers pre-installed for fast code navigation (~50ms vs 30-60s with grep):
| Language | Server | Plugin name |
|---|---|---|
| Go | gopls |
gopls-lsp@claude-plugins-official |
| TypeScript/JavaScript | typescript-language-server |
typescript-lsp@claude-plugins-official |
| Python | pyright |
pyright-lsp@claude-plugins-official |
Enable plugins in your ~/.claude/settings.json:
{
"enabledPlugins": {
"gopls-lsp@claude-plugins-official": true,
"typescript-lsp@claude-plugins-official": true,
"pyright-lsp@claude-plugins-official": true
}
}If running Claude Code on the host (without Docker), install the servers locally too:
# macOS (Homebrew)
brew install go pyright typescript-language-server
# gopls
go install golang.org/x/tools/gopls@latest| What | Where |
|---|---|
| Project mounts | config/docker-compose.local.yml |
| Environment overrides | config/.env (see config/.env.example) |
| Protected branches | GIT_PROTECTED_BRANCHES env var (default: main master) |
| Allowed docker commands | scripts/docker-wrapper.sh |
| Socket proxy API rules | docker-compose.yml socket-proxy command |
| GPG keys | gpg-keys/*.asc or *.gpg |
| AWS credential proxy | AWS_CRED_PROXY_PROFILES env var (see AWS Credentials) |
bash -n bin/claude-docker bin/claude-docker-ctrl bin/claude-docker-vscode-wrapper bin/claude-docker-jetbrains-wrapper bin/lib/session-cleanup.sh scripts/*.sh test/*.sh
make testCurrent tests cover:
- mount boundary logic
- credential helper quoting
- session PID file naming
- wrapper behavior with mocked
docker - start-time preflight/override generation with mocked
docker docker compose configrendering smoke test- VS Code wrapper forwarding
MIT