A Docker container for safely running agentic coding CLIs — Claude Code, OpenAI Codex, and Gemini — alongside MCP servers for Playwright, Chrome DevTools, Jira/Confluence, Figma, and Locize (GitHub is handled via the gh CLI).
TL;DR run Claude Code (et al) in YOLO mode to remove the constant hand-holding of approving commands, but don't let it nuke your home directory or GitHub main.
The goal is to give an agent broad freedom to read, write, and execute inside a sandboxed Linux environment without giving it the same freedoms on your host machine. The container runs as a non-root user, drops all Linux capabilities except a minimal set, has a memory cap, and only sees the project directories you explicitly mount in.
Top tip Use this with slash commands from humanlayer, mounted read-only at ~/.claude/commands.
- Coding agents:
claude,codex,gemini, plushumanlayer - MCP servers preconfigured for Claude Code (see MCP servers):
playwright— browser automation via accessibility snapshotschrome-devtools— performance profiling, network inspection, console debuggingatlassian— Jira and Confluence (remote, OAuth)figma— design context (remote, OAuth)locize— translation strings (remote, OAuth)
- Runtimes: Node.js 22, Bun, pnpm (via Corepack), Playwright's bundled Chromium
- Tooling: Docker CLI (via socket mount), GitHub CLI (
gh), AWS CLI v2, git, git-crypt, TypeScript & Python (Pyright) language servers, micro, vim, less, make, bash-completion - Quality of life: colored prompt with git branch, prompt-history helpers (
np/ep/lp/rp), adangerclaudealias forclaude --dangerously-skip-permissions, host-managed user skills mounted in (e.g. swappable spinner verb packs)
- Non-root user (
claude) inside the container. (Sudo is passwordless so install scripts work — the real isolation comes from the layers below, not from blocking sudo.) cap_drop: ALLwith onlyCHOWN,SETUID,SETGIDadded back.- Bridge networking — no host network access.
- 16 GB memory cap.
- No host filesystem access beyond the directories mounted in
docker-compose.yml.
Note: the Docker socket is mounted so the agent can use docker-in-docker. This is convenient but does give the container control of the host Docker daemon, so it is not a hard security boundary — treat it as part of the trust you extend to the tools you run.
node_modulesmounted by configuration as container-only storage, which is many times faster than on shared disk between host and container, and gives near native speeds, especially on full unit test runs.
# 1. Configure host paths and your GitHub tokens
cp .env.example .env
$EDITOR .env # set PROJECTS_DIR, GH_TOKEN_RW, GH_TOKEN_RO, GH_RW_TREE
# 2. Create your working copies of the git-ignored config files
cp CLAUDE.md.example CLAUDE.md # user-level agent memory (must exist before `up`)
cp claude.json.example claude.json # MCP server definitions (must exist before `build`)
cp gitconfig.example gitconfig # baked git identity + token auth (must exist before `build`)
$EDITOR CLAUDE.md # tweak to taste
$EDITOR gitconfig # set your name/email and the read-write tree path (match GH_RW_TREE)
# 3. (Optional) set up per-project node_modules volumes
cp docker-compose.override.yml.example docker-compose.override.yml
$EDITOR docker-compose.override.yml # see "node_modules speedup" below
# 4. Build and start
docker compose up -d --build
# 5. Drop into a shell
docker exec -it claude-pw bash
# Inside the container:
cd /workspace/your-project
dangerclaude # or: codex, gemini
# 6. One-time: authenticate the remote MCP servers
# Run `claude`, type `/mcp`, and complete the OAuth login for
# atlassian, figma, and locize. Logins persist in the volume.Created from .env.example. Sets host-side values used by docker-compose.yml.
| Variable | Default | Purpose |
|---|---|---|
PROJECTS_DIR |
../projects |
Host directory mounted at /workspace inside the container. Can be absolute or relative to docker-compose.yml. |
GH_TOKEN_RW |
(none) | Read-write fine-grained PAT, scoped to the project tree you want the agent to push to. |
GH_TOKEN_RO |
(none) | Read-only fine-grained PAT, the safe default for every other repo. |
GH_RW_TREE |
(none) | Absolute in-container path (under /workspace) of the read-write tree. The gh wrapper script and the baked git config use the read-write token there and the read-only token elsewhere. |
The .env file is git-ignored, so each user keeps their own.
A user-level CLAUDE.md is bind-mounted read-only into the container at ~/.claude/CLAUDE.md, giving every project in the container a shared baseline of instructions. It is bind-mounted (not baked into the image) because the claude-config named volume shadows ~/.claude/, so a copy baked there would not take effect.
cp CLAUDE.md.example CLAUDE.md
$EDITOR CLAUDE.mdCLAUDE.md is git-ignored (your personal copy); CLAUDE.md.example is the committed baseline. The file must exist before docker compose up, or Docker silently creates an empty directory at the mount path.
MCP servers are defined as config-as-code in claude.json, copied into the image at ~/.claude.json — the user-scope location Claude Code actually reads. (~/.claude/ is shadowed by the claude-config volume, so MCP config placed there does not take effect.) No secrets live in it: the remote servers use OAuth.
claude.json is git-ignored (your working copy); claude.json.example is the committed baseline — copy it on first setup (cp claude.json.example claude.json). It must exist before you build, since the Dockerfile copies it into the image.
playwright,chrome-devtools— local stdio servers; work out of the box.- GitHub — not exposed as an MCP server by default, to keep session context lean. Use the
ghCLI instead: it is pre-authenticated and token-aware by directory (read-write in the read-write tree, read-only elsewhere), matching the baked git config. If you want structured MCP access, addgithub-rw/github-roservers runningghcr.io/github/github-mcp-serverpinned toGH_TOKEN_RW/GH_TOKEN_RO(the read-only one withGITHUB_READ_ONLY=1). atlassian(Jira/Confluence),figma,locize— remote servers using OAuth. Authenticate once perclaude-configvolume: runclaude, type/mcp, and complete the browser login for each. Credentials persist in the volume, so you don't repeat this on rebuild.
Check status any time with claude mcp list. Because ~/.claude.json is baked into the image, recreating the container resets it to whatever claude.json held at build time — edit claude.json and rebuild to add servers, rather than relying on runtime claude mcp add.
Created from docker-compose.override.yml.example. Docker Compose auto-merges this with docker-compose.yml on every up / build.
Why it matters: bind-mounting node_modules from macOS or Windows into a Linux container is painfully slow. The cross-OS filesystem layer pays a per-file tax, and node_modules is hundreds of thousands of small files. Installs that take 20 seconds natively can take 10+ minutes through a bind mount, and runtime tools (tsc, vite, next) crawl.
The fix: shadow each project's node_modules directory with a named Docker volume, which lives on the container's native ext4 filesystem.
services:
claude:
volumes:
- my-project-node-modules:/workspace/my-project/node_modules
volumes:
my-project-node-modules:After adding an entry, run pnpm install (or npm / bun install) once inside the container to populate the volume. The trade-off: node_modules is no longer visible from the host, so editor features like "go to definition" into a dependency only work from inside the container (e.g. via VS Code's "Attach to Running Container").
If you also run the project natively on the host (e.g. pnpm dev on macOS as well as inside the container), you need to install on both sides. The named volume and the host's node_modules directory are separate filesystems — installing in one does not populate the other, and lockfile changes need to be re-applied wherever you run code. A common gotcha: bumping a dep on the host, then hitting "module not found" inside the container until you rerun the install there too.
This file is git-ignored, so each user maintains their own list of projects.
| Host | Container | Notes |
|---|---|---|
${PROJECTS_DIR} |
/workspace |
Your projects directory |
./CLAUDE.md |
/home/claude/.claude/CLAUDE.md |
read-only — user-level agent memory |
~/.claude/commands |
/home/claude/.claude/commands |
read-only — humanlayer slash commands |
~/.claude/skills |
/home/claude/.claude/skills |
read-only — host-managed user skills |
~/thoughts |
/home/claude/thoughts |
persistent agent notes |
~/prompts |
/home/claude/prompts |
prompt history (used by np/ep/lp/rp) |
/var/run/docker.sock |
/var/run/docker.sock |
docker-in-docker via host daemon |
claude-config (named) |
/home/claude/.claude |
persists Claude settings/history/MCP logins |
Git auth is not mounted. A directory-aware ~/.gitconfig is baked into the image (see gitconfig / gitconfig-ds): it uses the read-only token by default and the read-write token inside the read-write tree. The container holds no SSH keys — SSH-style GitHub remotes are rewritten to HTTPS so the token credential helper authenticates them.
The verbs Claude Code shows while it works (Thinking…, Analyzing…) can be
swapped for themed packs (pirate, superhero, wizard, …) from
awesome-claude-spinners,
via that repo's install-spinner skill.
Skills are managed on the Mac and mounted read-only into the container at
~/.claude/skills (the same pattern as the slash commands). Install the skill once
on the host:
npx --yes skills@latest add alexpl292/awesome-claude-spinners \
--global --agent claude-code --skill install-spinner --copy --yes--copy writes real files (not symlinks) to ~/.claude/skills/install-spinner, so
the read-only container mount resolves them. The skill is then available in both your
host and container Claude Code.
To use it, in a Claude Code session just ask:
install the pirate spinner pack # or superhero, wizard, cat, …
remove the spinner pack # revert to the defaults
The skill fetches the pack from GitHub and writes the chosen verbs to the
spinnerVerbs field of ~/.claude/settings.json. Because that file lives in the
claude-config volume, your pick persists across rebuilds, and only that one
field is touched. (Needs the network, which the container has, and a Claude Code
build new enough to support spinnerVerbs.)
For prompts longer than a single line, typing into the Claude Code TUI is awkward — newlines, paste, and editing are all clumsy. These aliases let you draft a prompt in micro (a real editor) and then reference it from the agent.
| Alias | Action |
|---|---|
np |
new prompt — opens a fresh timestamped .md file in micro and symlinks it as latest.md |
ep |
edit prompt — reopen the latest prompt to tweak it |
lp |
list prompt — print the latest prompt to the terminal |
rp |
recent prompts — show the last 20 prompt files, newest first |
Prompts live in ~/prompts/ inside the container, which is mounted to ~/prompts/ on the host — so they survive container rebuilds and are searchable from either side.
Typical flow:
np # write your prompt, save & quit micro
claude # (or dangerclaude / codex / gemini)
> @~/prompts/latest.md # Claude Code reads the file as the promptThe @ prefix is Claude Code's file-reference syntax. Iterate by hitting ep to edit, then re-referencing @~/prompts/latest.md in a new turn. rp is useful for grabbing an older prompt by timestamp when you want to reuse or fork it.
Claude Code's background auto-updater is disabled in the image (DISABLE_AUTOUPDATER=1), so it can't drift to a new version mid-task or leave a stale update lock. The installed version is whatever @latest resolved to at build time. To move to a newer Claude Code (or pick up a model like Opus 4.8, which needs a recent enough build), rebuild the image.
Docker layer caching can defeat this: the npm install -g ... @latest layer is cached, so if nothing above it changed you'll get the same version back. Changing the base image or anything earlier in the Dockerfile busts the cache; otherwise force it:
docker compose build --no-cache # or: --pull
docker compose up -d --force-recreate
docker compose exec claude claude --versiondocker compose up -d --build # build & start
docker compose up -d --build --force-recreate # rebuild + replace the container
# (needed for zombie-reaping init and baked-config changes)
docker compose down # stop & remove
docker exec -it claude-pw bash # shell in
docker compose logs -f claude # tail logs
docker compose build --no-cache # force a clean rebuild after Dockerfile changes
docker compose exec claude claude --version # check the installed Claude Code versionIf claude ever reports Another instance is currently performing an update, a previous update died and left a lock. Remove it (it lives in the persistent claude-config volume) and retry:
docker compose exec claude rm -f /home/claude/.claude/scheduled_tasks.lock.
├── Dockerfile # image definition
├── docker-compose.yml # base service config (committed)
├── docker-compose.override.yml.example # template for per-user node_modules volumes
├── .env.example # template for host paths & secrets
├── claude.json.example # MCP server definitions template (committed)
├── claude.json # your MCP config (git-ignored, copied from the example)
├── CLAUDE.md.example # user-level agent memory template (committed)
├── CLAUDE.md # your personal agent memory (git-ignored, copied from the example)
├── gitconfig.example # baked git identity/credential template (committed)
├── gitconfig # your git config (git-ignored, copied from the example)
├── gitconfig-ds # read-write credential override (committed, token from env)
├── aliases.sh # shell prompt + helpers, baked into the image
├── gh-wrapper.sh # token-aware gh wrapper, installed at /usr/local/bin/gh
├── .gitignore
└── .claude/ # tool permissions for the host-side Claude Code