A devcontainer setup for running Claude Code inside a containerized development environment. Run multiple workspaces simultaneously on the same machine, sharing a single Claude Code subscription — with credentials, conversation history, and usage tracking unified across all containers through a shared Docker volume.
Includes bare git repo initialization for parallel worktrees, Serena MCP server management, a layered Claude Code component system, and shell utilities.
I run Claude Code exclusively in devcontainers to allow me to safely run --dangerously-skip-permissions on a regular basis. It's definitely saved my bacon a few times.
| Component | Description |
|---|---|
| Dockerfile | .NET 8 SDK base, Node.js 20, Claude Code CLI, Serena MCP server, Playwright (optional) |
| post-create.sh | One-time setup: symlink Claude components, configure MCP, create shell aliases |
| post-start.sh | Every-start setup: bare git init, start Serena, source shell config |
| common-functions.sh | Shared library: component linking, worktree management, alias/function injection |
| devcontainer.json.template | Full-featured template with all mount categories and placeholders |
| examples/minimal/ | Working minimal config for standard layout |
- Copy
.devcontainer/into your project root - Copy
examples/minimal/devcontainer.jsonto.devcontainer/devcontainer.json - Edit the paths (see Configuring Mounts)
- Open in VS Code → "Reopen in Container"
The devcontainer supports two layouts, controlled by the WORKSPACE_LAYOUT build arg and env var.
/workspaces/myproject/
├── .claude/ # Claude Code config (symlinked from mounts)
├── .devcontainer/ # This config
├── src/ # Your code (bound from host)
├── tests/
├── package.json
└── claude_files/ # Claude output (optional)
Use this when you don't need parallel worktree execution. Your code is bound directly into the workspace root. Git uses the normal .git/ directory from your host (or a fresh init inside the container).
/workspaces/myproject/
├── main/ # Code files (bound from host)
│ ├── src/
│ ├── tests/
│ └── package.json
├── worktrees/ # Parallel worktrees (local to container)
│ ├── wt-unit-1/
│ └── wt-unit-2/
├── .git/ # Bare git database (local to container)
├── .claude/ # Claude Code config (symlinked)
├── .devcontainer/
└── claude_files/
Use this when you want Claude Code's /worktree skill or PRD parallel builds. Code files are bound into main/, and the container creates a bare git repo with main as a linked worktree. Additional worktrees are created on-the-fly for parallel agent execution.
Key difference: In worktree layout, .git/ is not bound from the host — it's created fresh inside the container. This gives Claude a sandboxed git environment where it can freely create branches and worktrees without affecting your host repo.
| Scenario | Layout |
|---|---|
| General Claude Code usage | standard |
Parallel agent execution (/worktree, /prd build) |
worktree |
| You want Claude's git ops isolated from your host | worktree |
| Simple setup, no special requirements | standard |
The devcontainer.json.template uses {{PLACEHOLDER}} syntax. Here's what each one means and how to fill it in.
| Placeholder | Description | Example |
|---|---|---|
{{CONTAINER_NAME}} |
Display name in VS Code | "My Project - Claude Code" |
{{WORKSPACE_NAME}} |
Directory name under /workspaces/ |
"myproject" |
{{WORKSPACE_LAYOUT}} |
"standard" or "worktree" |
"standard" |
{{HOST_CODE_DIR}} |
Absolute path to your code on the host | "/home/you/projects/myproject" |
| Placeholder | Description | Example |
|---|---|---|
{{HOST_CLAUDE_SHARED}} |
Path to shared Claude Code components | "/home/you/claude-code-tools/.claude" |
{{HOST_CLAUDE_WS}} |
Path to workspace-specific Claude overrides | "/home/you/claude-configs/myproject" |
{{HOST_SERENA_DIR}} |
Path to .serena/ config for your project |
"/home/you/projects/myproject/.serena" |
{{HOST_OUTPUT_DIR}} |
Path for Claude output files | "/home/you/projects/myproject/claude_files" |
Standard layout — bind the whole project root:
Worktree layout — bind individual directories and files into main/:
"source={{HOST_CODE_DIR}}/src,target=${containerWorkspaceFolder}/main/src,type=bind,consistency=cached",
"source={{HOST_CODE_DIR}}/tests,target=${containerWorkspaceFolder}/main/tests,type=bind,consistency=cached",
"source={{HOST_CODE_DIR}}/package.json,target=${containerWorkspaceFolder}/main/package.json,type=bind,consistency=cached",
"source={{HOST_CODE_DIR}}/.gitignore,target=${containerWorkspaceFolder}/main/.gitignore,type=bind,consistency=cached"Why individual binds for worktree layout? Because .git/ must not be bound — the container creates its own bare repo. Binding the entire project root would include .git/, breaking the worktree setup. List each top-level directory and config file separately.
If you use claude-code-tools or a similar setup, bind the component directories so the post-create script can symlink them:
// Shared components (available to all workspaces)
"source=/home/you/claude-code-tools/.claude/settings.json,target=${containerWorkspaceFolder}/.claude/settings.json,type=bind",
"source=/home/you/claude-code-tools/.claude/commands,target=/tmp/claude-shared/commands,type=bind",
"source=/home/you/claude-code-tools/.claude/agents,target=/tmp/claude-shared/agents,type=bind",
"source=/home/you/claude-code-tools/.claude/rules,target=/tmp/claude-shared/rules,type=bind",
"source=/home/you/claude-code-tools/.claude/hooks,target=/tmp/claude-shared/hooks,type=bind",
"source=/home/you/claude-code-tools/.claude/skills,target=/tmp/claude-shared/skills,type=bind",
"source=/home/you/claude-code-tools/.claude/statusline,target=/tmp/claude-shared/statusline,type=bind",The devcontainer supports a two-tier component system: shared components (common across workspaces) and workspace-specific overrides.
Claude Code automatically discovers and loads files from specific directories (.claude/commands/, .claude/agents/, .claude/rules/, etc.) into its context. The challenge is getting your tools into those directories inside the container in a way that's:
- Reusable — shared tools (skills, agents, rules) are written once and available in every workspace
- Overridable — each workspace can add specialized tools that only apply to that project
- Editable live — because these are bind mounts (not copies), you can edit files, add new agents, or tweak rules from your host machine while the container is running — changes appear immediately inside the container. Rebuild the container to re-run the symlink setup if you add new component directories.
The bind mount + symlink approach solves all three. Host directories are bound into staging paths (/tmp/claude-shared/, /tmp/claude-workspace/), then the post-create script wires them into .claude/ where Claude Code expects to find them.
| Tier | Mounted to | Purpose | Example |
|---|---|---|---|
| Shared | /tmp/claude-shared/ |
Tools used across all workspaces | Generic PRD agents, worktree scripts, statusline |
| Workspace | /tmp/claude-workspace/ |
Tools for one specific project | Project-specific rules, test patterns, specialized agents |
On the host, you'd organize this as two separate directories — one for shared tools (e.g., your claude-code-tools clone) and one per workspace for project-specific overrides:
~/claude-configs/
├── shared/ # Shared across all workspaces
│ ├── settings.json
│ ├── commands/
│ ├── agents/
│ ├── rules/
│ ├── hooks/
│ ├── skills/
│ └── statusline/
├── project-alpha/ # Workspace-specific for project-alpha
│ ├── rules/
│ ├── agents/
│ └── skills/
└── project-beta/ # Workspace-specific for project-beta
└── rules/
Host Container .claude/ (what Claude sees)
───────────────────────────────── ───────────────────────────── ─────────────────────────────
shared/ /tmp/claude-shared/ .claude/commands/ -> shared
commands/ ──bind──> commands/ └── ~WORKSPACE/ -> workspace
agents/ ──bind──> agents/ .claude/agents/ -> shared
rules/ ──bind──> rules/ └── ~WORKSPACE/ -> workspace
hooks/ ──bind──> hooks/ .claude/rules/ -> shared
skills/ ──bind──> skills/ └── ~WORKSPACE/ -> workspace
statusline/ ──bind──> statusline/
project-alpha/ /tmp/claude-workspace/
rules/ ──bind──> rules/ ──symlink──> shared/rules/~WORKSPACE/
agents/ ──bind──> agents/ ──symlink──> shared/agents/~WORKSPACE/
skills/ ──bind──> skills/ ──symlink──> .claude/skills/ (project-level)
The link_claude_components function in common-functions.sh handles all the symlink wiring:
.claude/{component}/symlinks to/tmp/claude-shared/{component}/- If
/tmp/claude-workspace/{component}/exists, it's symlinked as a~WORKSPACE/subdirectory inside the shared component - Claude Code discovers both shared and workspace-specific files through the unified
.claude/tree
Skills are special: Claude Code requires a flat directory for skill discovery. Shared skills go to ~/.claude/skills/ (personal-level) and workspace skills go to .claude/skills/ (project-level). A browsability symlink at .claude/~SHARED/skills/ makes shared skills visible in the VS Code file explorer.
If you only need shared components (most common), skip the workspace mount section entirely. The post-create script handles missing workspace directories gracefully.
A core design goal of this devcontainer is running multiple workspaces simultaneously on a single Claude Code subscription. All containers mount the same claude-code-home Docker volume at ~/.claude/, which means:
- Single authentication — log in once, all containers share the credentials
- Unified conversation history — every workspace's
.jsonltranscripts live in the same~/.claude/projects/directory - Cross-workspace usage tracking — the usage estimator script scans all transcripts in
~/.claude/projects/, so it captures token consumption from every workspace sharing that volume — giving you a single usage percentage against your subscription's 5-hour budget - Shared session state — the session reset timer (
~/.claude/claude-code-session-state) is shared, so all containers agree on when the usage window resets - Persistent auto-memory — Claude's memories persist across container rebuilds and are available in every workspace
┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
│ Workspace A │ │ Workspace B │ │ Workspace C │
│ (project-alpha) │ │ (project-beta) │ │ (project-gamma) │
│ │ │ │ │ │
│ ~/.claude/ ────────┼──┼──────────┼──────────┼──┼──> claude-code-home │
│ │ │ │ │ │ (Docker volume) │
└─────────────────────┘ └──────────┼──────────┘ └─────────────────────┘
│
┌──────────▼──────────┐
│ ~/.claude/ │
│ ├── .credentials.json (shared auth)
│ ├── projects/ (all transcripts)
│ │ ├── -ws-alpha/ (workspace A)
│ │ ├── -ws-beta/ (workspace B)
│ │ └── -ws-gamma/ (workspace C)
│ ├── claude-code-session-state (shared timer)
│ └── CLAUDE.md (shared memory)
└─────────────────────────┘
Every devcontainer.json in the template already includes this mount:
"source=claude-code-home,target=/home/node/.claude,type=volume"Because claude-code-home is a named volume (not a bind mount), Docker creates it once and reuses it for every container that references it by name. No extra configuration is needed — just use the same volume name across all your devcontainers.
If you need to isolate a workspace from the shared subscription (e.g., a different account), give it a different volume name:
"source=claude-code-home-other-account,target=/home/node/.claude,type=volume"| Volume | Purpose | Shared Across Containers |
|---|---|---|
claude-code-home |
~/.claude/ — auth, transcripts, session state, auto-memory |
Yes |
npm-cache |
npm download cache | Yes |
npm-global-packages |
Global npm packages (Playwright, etc.) | Yes |
claude-mcp-servers |
Serena MCP server installation | Yes |
The claude-code-home volume is the foundation of the multi-workspace model. All other volumes are shared for build performance — they avoid re-downloading packages across container rebuilds and workspaces.
The post-create script injects these into ~/.bashrc:
| Alias | Command |
|---|---|
cc |
clear && claude |
ccr |
clear && claude --resume |
ccdsp |
clear && claude --dangerously-skip-permissions |
ccmh / ccms / ccmo |
Launch with Haiku / Sonnet / Opus |
ccu |
Fetch actual usage from Anthropic API |
cceu |
Run estimated usage calculator |
cccs |
Print model cheatsheet |
| Alias | Command |
|---|---|
ws |
cd $WORKSPACE_DIR |
wsm |
cd $WORKSPACE_DIR/main (worktree layout) |
wswt |
cd $WORKSPACE_DIR/worktrees (worktree layout) |
| Alias | Command |
|---|---|
serena-start |
Start Serena MCP server (foreground) |
serena-start-bg |
Start in background |
serena-log |
Tail Serena log |
| Alias | Command |
|---|---|
cwt / wtc |
Clean up stale worktrees (pattern: wt-*) |
| Function | Description |
|---|---|
set_session_reset <time> |
Override usage session reset time (e.g., set_session_reset "10:30pm") |
fetch_claude_usage |
Curl Anthropic OAuth usage API with your credentials |
clean_worktrees [pattern] |
Remove worktrees matching pattern (default: wt-*) |
The setup scripts source optional extension scripts for per-workspace additions:
| Script | When | Use For |
|---|---|---|
scripts/post-create-extra.sh |
After post-create | Extra package installs, workspace-specific aliases |
scripts/post-start-extra.sh |
After post-start | Workspace-specific services, env setup |
Create these files alongside the other scripts. They're sourced (not executed), so they have access to all variables and functions from common-functions.sh.
The Dockerfile uses build args to toggle features:
| Arg | Default | Description |
|---|---|---|
WORKSPACE_DIR |
(required) | Container workspace path |
WORKSPACE_LAYOUT |
standard |
standard or worktree |
INSTALL_PLAYWRIGHT |
false |
Install Playwright + Chromium/Firefox/Edge |
USERNAME |
node |
Container user |
USER_UID / USER_GID |
1000 |
User/group IDs |
The default base is mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim. Change this to match your stack:
# Node.js project
FROM node:20-bookworm-slim
# Python project
FROM python:3.12-bookworm-slim
# Generic (no SDK)
FROM debian:bookworm-slimIf you change the base image, remove or adjust the .NET-specific sections and ensure git, jq, bc, curl, and Node.js are still installed (required by Claude Code and the setup scripts).
Serena is installed at build time from a pinned commit. To update:
RUN cd /tmp/mcp-servers/serena \
&& git checkout <new-commit-hash> \
&& uv syncTo remove Serena entirely, delete the Serena sections from the Dockerfile, post-create.sh, post-start.sh, and common-functions.sh.
- Docker Desktop or compatible Docker engine
- VS Code with the Dev Containers extension
- A Claude Code subscription (Pro, Max, or Enterprise)
MIT
"source={{HOST_CODE_DIR}},target=${containerWorkspaceFolder},type=bind,consistency=cached"