A practical guide to sandboxing Claude Code with DevContainers -- standardized environments, scoped permissions, and reproducible configuration for team workflows.
- Quick Start -- get running in 5 minutes
- Prerequisites
- Why This Guide Exists
- The Architecture: Wide Read, Narrow Write
- Why Inside the Repo
- Project Structure
- Step-by-Step Setup -- Dockerfile, container config, firewall, MCP servers, permissions, CLAUDE.md, skills, .gitignore, verification
- The Scope Confusion: User vs Project vs Local
- Security Model -- threat model, hardening details, SSH/git push, known limitations
- Before and After
- CI/CD Authentication
- Cheat Sheet: How to Add Things
- Key Takeaways
- Further Reading
- References
For those who want to get running first and read later:
# 1. Create the devcontainer in your repo
mkdir -p .devcontainer
# Add Dockerfile, devcontainer.json, init-firewall.sh (see Step-by-Step Setup)
# 2. Add Claude Code config
mkdir -p .claude/skills .claude/rules
# Add .claude/settings.json (permissions)
# Add .mcp.json (MCP servers)
# Add CLAUDE.md (project instructions)
# 3. Open in VS Code
code .
# Cmd+Shift+P -> "Dev Containers: Reopen in Container"
# 4. Authenticate MCP servers
# Type /mcp, select Figma, click Authenticate
# Type /mcp, select Supabase, click Authenticate
# 5. Start coding
claude --dangerously-skip-permissionsOr add Claude Code to any existing devcontainer without a custom Dockerfile:
The rest of this guide explains why each piece exists and how to customize it.
- Docker Desktop (or Docker Engine on Linux)
- VS Code with the Dev Containers extension
- A Claude Code account (Anthropic API key or Claude Pro/Team subscription)
AI coding agents are force multipliers. They refactor, generate tests, wire up boilerplate, and handle the mechanical work that consumes a disproportionate share of engineering hours. But every capability they offer requires a corresponding permission: file system access, command execution, network calls, access to environment variables. For an individual developer on a personal project, the tradeoff is straightforward. For a team shipping production code -- with shared credentials, deployment pipelines, and compliance requirements -- it demands architecture.
The default setup has two failure modes:
- Too open -- The agent operates with the same access as the developer. It can write anywhere, execute arbitrary commands, and reach any network endpoint.
- Too locked down -- Every command requires manual approval. The overhead negates the productivity gain. Developers disable protections out of friction.
Treating AI agent setup as an infrastructure problem -- not an individual preference -- changes the outcome. This guide covers how to set up a standardized, sandboxed environment for AI coding agents across a development team. We use Claude Code and DevContainers as the reference implementation, but the principles apply broadly: isolate the execution environment, enforce a default-deny network policy, scope file system access to the project boundary, and version the entire configuration so every engineer gets the same guardrails on clone.
Claude Code should have maximum context (read) with minimum blast radius (write).
INPUTS (read-only) OUTPUT (read-write)
================ ==================
+------------------+
| MCP: Figma |---+
| (design tokens) | |
+------------------+ |
|
+------------------+ |
| MCP: Supabase |---+
| (read_only=true) | |
+------------------+ | +------------------------+
+---->| | +------------------+
+------------------+ | | Claude Code |---->| /workspace |
| MCP: Context7 |---+ | (inside container) | | (your project) |
| (library docs) | | | | +------------------+
+------------------+ +---->| | ONLY writable
| +------------------------+ persistent path
+------------------+ | |
| MCP: shadcn |---+ |
| (component reg.) | | |
+------------------+ | BLOCKED:
| - Host filesystem
+------------------+ | - Other repos
| CLI: git, npm, |---+ - Home directory
| npx, node, pnpm | - Unwhitelisted network
+------------------+
+------------------+
| MCP: Playwright |--+
| (browser testing)|
+------------------+
This is enforced at four layers:
| Layer | What it does | Enforced by |
|---|---|---|
| 1. Container isolation | Separate filesystem and process space from host | Docker |
| 2. Network firewall | Default-deny outbound; only whitelisted domains | iptables (init-firewall.sh) |
| 3. Filesystem mounts | Project dir = read-write; everything else absent | Docker bind mounts |
| 4. Claude Code permissions | Tool-level allow/deny rules + OS sandbox | .claude/settings.json |
No single layer is sufficient. Together, they create defense in depth.
The devcontainer configuration (.devcontainer/) belongs inside your repository. This is not a personal preference -- it's the only approach that works for teams.
| Factor | Inside repo | Outside repo |
|---|---|---|
| Team onboarding | Clone + "Reopen in Container" -- done | Every dev sets up their own environment |
| Consistency | Version-controlled; everyone runs the same thing | Diverges silently across machines |
| GitHub Codespaces | Required -- Codespaces reads .devcontainer/ from the repo |
Not supported |
| CI/CD reuse | Same container definition runs in CI | CI can't access dev-local configs |
| Code review | Container changes go through PR review | No visibility into what changed |
| Config evolution | Add a dependency? Update Dockerfile in the same PR | Hope everyone remembers to update locally |
The one legitimate reason to go outside: you don't own the repo (open-source contribution, vendor code). For everything else, commit it.
Shell themes, editor keybindings, font size -- these are not devcontainer concerns. Use a dotfiles repo and VS Code Settings Sync. The devcontainer defines the team standard; your dotfiles define your taste.
Here's the complete file tree for a Next.js project with Claude Code devcontainer:
your-nextjs-app/
.devcontainer/
devcontainer.json # Container config
Dockerfile # Image definition + CLIs
init-firewall.sh # Network whitelist
.claude/
settings.json # Permission rules (committed, team-shared)
settings.local.json # Personal overrides (NOT committed -- see .gitignore)
skills/
generate-page/
SKILL.md # Custom slash command: /generate-page
agents/
frontend-reviewer.md # Custom subagent
rules/
code-style.md # Always-loaded instructions
api-patterns.md # Loaded when touching src/api/**
.mcp.json # MCP servers (committed, team-shared)
CLAUDE.md # Project instructions for Claude
src/ # Your Next.js app
package.json
next.config.ts
...
Install your stack's CLIs here. This is where you answer "how do I add a CLI?"
# .devcontainer/Dockerfile
FROM node:20
# System tools
RUN apt-get update && apt-get install -y --no-install-recommends \
git sudo zsh fzf jq vim curl unzip gnupg2 gh \
iptables ipset iproute2 dnsutils aggregate \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Workspace
RUN mkdir -p /workspace /home/node/.claude \
&& chown -R node:node /workspace /home/node/.claude
# Shell history persistence
RUN mkdir /commandhistory && touch /commandhistory/.bash_history \
&& chown -R node /commandhistory
ENV DEVCONTAINER=true
WORKDIR /workspace
USER node
ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global
ENV PATH=$PATH:/usr/local/share/npm-global/bin
ENV SHELL=/bin/zsh
# ============================================
# ADD YOUR CLIs HERE
# ============================================
# Claude Code
ARG CLAUDE_CODE_VERSION=latest
RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}
# Supabase CLI (skip if using Convex)
RUN npm install -g supabase
# Convex CLI (skip if using Supabase)
RUN npm install -g convex
# pnpm (if your project uses it)
RUN npm install -g pnpm
# shadcn does NOT need a global install -- use: pnpm dlx shadcn@latest
# Playwright does NOT need a global install -- used via MCP or npx
# ============================================
# Firewall setup
# ============================================
COPY init-firewall.sh /usr/local/bin/
USER root
RUN chmod +x /usr/local/bin/init-firewall.sh \
&& echo "node ALL=(root) NOPASSWD: /usr/local/bin/init-firewall.sh" \
> /etc/sudoers.d/node-firewall \
&& chmod 0440 /etc/sudoers.d/node-firewall
USER nodeHow to add a CLI: Add a RUN npm install -g <package> line in the marked section. Rebuild the container. That's it. If a CLI doesn't need a global install (like shadcn), use npx or pnpm dlx at runtime instead.
// .devcontainer/devcontainer.json
{
"name": "Next.js + Claude Code",
"build": {
"dockerfile": "Dockerfile",
"args": {
"CLAUDE_CODE_VERSION": "latest"
}
},
"runArgs": [
"--cap-drop=ALL",
"--cap-add=NET_ADMIN",
"--cap-add=NET_RAW",
"--security-opt=no-new-privileges:true"
],
// YOUR PROJECT: the only writable persistent path
"workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=delegated",
"workspaceFolder": "/workspace",
"mounts": [
// Claude state -- survives container rebuilds
"source=claude-config-${devcontainerId},target=/home/node/.claude,type=volume",
// Shell history -- survives container rebuilds
"source=claude-history-${devcontainerId},target=/commandhistory,type=volume",
// Git identity -- read-only from host
"source=${localEnv:HOME}/.gitconfig,target=/home/node/.gitconfig,type=bind,readonly"
],
"remoteUser": "node",
"postStartCommand": "sudo /usr/local/bin/init-firewall.sh",
"waitFor": "postStartCommand",
"postCreateCommand": "npm install",
"forwardPorts": [3000],
"portsAttributes": {
"3000": { "label": "Next.js Dev", "onAutoForward": "notify" }
},
"containerEnv": {
"NODE_OPTIONS": "--max-old-space-size=4096",
"CLAUDE_CONFIG_DIR": "/home/node/.claude",
"NEXT_TELEMETRY_DISABLED": "1",
// Neutralize VS Code IPC sockets that agents could exploit to escape the sandbox
"VSCODE_IPC_HOOK_CLI": "",
"VSCODE_GIT_IPC_HANDLE": "",
"GIT_ASKPASS": ""
},
// Pass secrets from host env vars (never hardcode them)
"remoteEnv": {
"SUPABASE_URL": "${localEnv:SUPABASE_URL}",
"SUPABASE_ANON_KEY": "${localEnv:SUPABASE_ANON_KEY}"
},
"customizations": {
"vscode": {
"extensions": [
"anthropic.claude-code",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"bradlc.vscode-tailwindcss",
"eamodio.gitlens"
],
"settings": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"terminal.integrated.defaultProfile.linux": "zsh"
}
}
}
}| Mount | Type | Why |
|---|---|---|
/workspace |
bind, read-write | Your project. The only place Claude writes. Visible on host instantly. |
~/.gitconfig |
bind, read-only | Claude commits with your identity but can't modify your git config. |
~/.claude |
named volume | Persists Claude state between rebuilds. Not on your host. |
/commandhistory |
named volume | Shell history survives restarts. Disposable. |
~/.ssh |
not mounted | SSH keys are deliberately excluded. The agent cannot git push to remotes that require SSH auth. If your workflow requires pushing, mount the key read-only and add a deny rule for Bash(git push *) in .claude/settings.json to keep the decision explicit. |
| Everything else | not mounted | The container simply cannot see your host home, other repos, or system files. |
Important: Every MCP server or external service you add in later steps needs its domain whitelisted here. If an MCP server can't connect, check this file first.
Customize the domain whitelist for your stack:
#!/bin/bash
# .devcontainer/init-firewall.sh
set -euo pipefail
# Preserve Docker DNS
DOCKER_DNS_RULES=$(iptables-save 2>/dev/null | grep "127.0.0.11" || true)
# Flush existing rules
iptables -F
iptables -X
ipset destroy allowed-domains 2>/dev/null || true
# Restore Docker DNS
if [ -n "$DOCKER_DNS_RULES" ]; then
echo "$DOCKER_DNS_RULES" | iptables-restore --noflush
fi
# Allow basics
iptables -A OUTPUT -o lo -j ACCEPT
iptables -A OUTPUT -p udp --dport 53 -j ACCEPT
iptables -A OUTPUT -p tcp --dport 22 -j ACCEPT
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
# Create IP set for allowed domains
ipset create allowed-domains hash:net
# ============================================
# WHITELIST YOUR DOMAINS HERE
# ============================================
whitelist() {
for ip in $(dig +short "$1" | grep -E '^[0-9]'); do
ipset add allowed-domains "$ip/32" 2>/dev/null || true
done
}
# Claude Code (required)
whitelist api.anthropic.com
whitelist statsig.anthropic.com
whitelist sentry.io
# Package registries
whitelist registry.npmjs.org
whitelist registry.yarnpkg.com
# GitHub
for cidr in $(curl -s https://api.github.com/meta | jq -r '.web[], .api[], .git[]' 2>/dev/null); do
ipset add allowed-domains "$cidr" 2>/dev/null || true
done
# Supabase (skip if using Convex)
whitelist api.supabase.com
whitelist mcp.supabase.com
# Add your project's Supabase URL:
# whitelist <your-project>.supabase.co
# Convex (skip if using Supabase)
whitelist api.convex.dev
# Figma MCP
whitelist mcp.figma.com
whitelist api.figma.com
# VS Code extensions
whitelist marketplace.visualstudio.com
whitelist vscode.blob.core.windows.net
# Vercel (if deploying)
# whitelist api.vercel.com
# whitelist vercel.com
# Context7 (library docs)
whitelist mcp.context7.com
# ============================================
# Allow host network (Docker host access)
HOST_IP=$(ip route | grep default | awk '{print $3}')
ipset add allowed-domains "$HOST_IP/32" 2>/dev/null || true
# Default deny
iptables -P OUTPUT DROP
iptables -A OUTPUT -m set --match-set allowed-domains dst -j ACCEPT
iptables -A OUTPUT -j REJECT --reject-with icmp-admin-prohibited
echo "Firewall initialized. Allowed domains loaded."MCP servers give Claude Code read access to external systems. Define them in .mcp.json at the project root so the whole team shares them.
// .mcp.json (committed to repo)
{
"mcpServers": {
"figma": {
"type": "http",
"url": "https://mcp.figma.com/mcp"
},
"supabase": {
"type": "http",
"url": "https://mcp.supabase.com/mcp?read_only=true"
},
"shadcn": {
"command": "npx",
"args": ["shadcn@latest", "mcp"]
},
"context7": {
"command": "npx",
"args": ["-y", "@upstash/context7-mcp"]
},
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest", "--headless"]
}
}
}After opening the container, run /mcp inside Claude Code to authenticate the HTTP servers (Figma, Supabase) via OAuth.
How to add an MCP server: Two ways.
Via CLI:
# Add to project scope (writes to .mcp.json, committed to git)
claude mcp add --transport http figma --scope project https://mcp.figma.com/mcp
# Add a stdio server to project scope
claude mcp add --transport stdio shadcn --scope project -- npx shadcn@latest mcp
# Add to user scope (only you, across all projects)
claude mcp add --transport http notion --scope user https://mcp.notion.com/mcp
# Add to local scope (only you, only this project -- default)
claude mcp add --transport http my-debug-server https://localhost:8080/mcpOr edit .mcp.json directly (for project scope).
| Server | Transport | Access | What it does |
|---|---|---|---|
| Figma | HTTP | Read-write | Pull design tokens, frames, layout data. Push generated UI back to canvas. |
| Supabase | HTTP | Read-only with ?read_only=true |
Query tables, inspect schema, read migrations. Add ?features=database,docs to limit scope. |
| Convex | stdio | Read-write (prod disabled by default) | Query data, inspect functions, read logs. Use --disable-tools data,run to restrict further. |
| shadcn | stdio | Read-only | Look up component registry, get install commands, read component docs. |
| Context7 | stdio/HTTP | Read-only | Fetch up-to-date docs for any library (Next.js, React, Tailwind, etc.). |
| Playwright | stdio | Read-write | Headless browser automation for testing. Use --allowed-hosts to restrict navigation. |
| Notion | HTTP | Read-write | Read project specs, PRDs, meeting notes. |
| GitHub | HTTP | Read-write | PRs, issues, code search. |
| Sentry | HTTP | Read-only | Error monitoring, stack traces. |
This file works alongside .mcp.json from Step 4. The mcp__<server-name>__* rules below reference the server names you defined in .mcp.json (e.g., mcp__figma__* matches all tools from the "figma" server).
// .claude/settings.json (committed to repo, shared with team)
{
"permissions": {
"allow": [
"Read",
"Bash(git *)",
"Bash(npm *)",
"Bash(pnpm *)",
"Bash(npx *)",
"Bash(node *)",
"Bash(jq *)",
"Bash(supabase *)",
"Bash(convex *)",
"mcp__figma__*",
"mcp__supabase__*",
"mcp__shadcn__*",
"mcp__context7__*",
"mcp__playwright__*"
],
"deny": [
"Bash(rm -rf *)",
"Bash(curl * | *)",
"Bash(wget *)",
"Read(./.env)",
"Read(./.env.*)"
]
}
}<!-- CLAUDE.md at project root (committed) -->
# My Next.js App
## Stack
- Next.js 15 (App Router)
- TypeScript
- Tailwind CSS + shadcn/ui
- Supabase (auth, database, storage)
- Deployed on Vercel
## Conventions
- Use `pnpm` for package management
- Components go in `src/components/ui/` (shadcn) or `src/components/` (custom)
- Server actions in `src/app/_actions/`
- Use `pnpm dlx shadcn@latest add <component>` to add shadcn components
- Run `pnpm dev` to start the dev server on port 3000
- Run `pnpm test` to run tests
## Do NOT
- Modify `.env.local` -- credentials are managed outside this repo
- Push directly to main -- always create a feature branchSkills are slash commands your team can invoke with /skill-name. They live in .claude/skills/.
<!-- .claude/skills/generate-page/SKILL.md -->
---
name: generate-page
description: Scaffold a new Next.js page with loading, error, and layout files
argument-hint: <route-path>
allowed-tools: Read, Write, Edit, Bash, Glob, Grep
---
Generate a new Next.js App Router page at the route: $ARGUMENTS
Steps:
1. Check if the route already exists in src/app/
2. Create the page.tsx, loading.tsx, and error.tsx files
3. Use the existing layout patterns from @src/app/layout.tsx
4. Use shadcn components for UI -- check available ones with the shadcn MCP
5. If the page needs data, create a server action in src/app/_actions/
6. Run `pnpm lint` to verify no errorsUsage inside Claude Code: /generate-page dashboard/settings
How to add a skill: Create a folder in .claude/skills/<name>/ with a SKILL.md file. That's it. The skill appears immediately as /<name> in Claude Code.
Add these lines to your .gitignore to keep personal config out of version control:
# Claude Code local settings (personal overrides, not shared)
.claude/settings.local.jsonAfter opening the container ("Reopen in Container"), run these checks:
# 1. Confirm you're inside the container
echo $DEVCONTAINER # Should print "true"
# 2. Confirm Claude Code is installed
claude --version
# 3. Confirm the firewall is active (should fail)
curl -s --max-time 3 https://example.com && echo "FAIL: firewall not active" || echo "OK: firewall blocking"
# 4. Confirm whitelisted domains work (should succeed)
curl -s --max-time 3 https://api.anthropic.com > /dev/null && echo "OK: Anthropic API reachable" || echo "FAIL: check firewall"
# 5. Confirm MCP servers are connected
# Inside Claude Code, type: /mcp
# All servers should show "connected" or prompt for authentication
# 6. Confirm write isolation
touch /tmp/test-write && echo "OK: /tmp writable" || echo "FAIL"
touch /workspace/test-write && rm /workspace/test-write && echo "OK: /workspace writable" || echo "FAIL"If step 3 does not fail (i.e., example.com is reachable), the firewall did not initialize. Check that postStartCommand ran successfully and that the container has NET_ADMIN capability.
When you add an MCP server, permission rule, hook, or skill, Claude Code asks which scope. Here's the decision tree:
Should the whole team have this?
├─ YES → Project scope (.claude/settings.json or .mcp.json)
│ Committed to git. Everyone gets it on clone.
│
└─ NO → Is it for all your projects, or just this one?
├─ ALL projects → User scope (~/.claude/settings.json)
│ Personal. Never committed.
│
└─ JUST THIS ONE → Local scope (.claude/settings.local.json)
Personal + gitignored.
| Thing | User (you, all projects) | Project (team, this repo) | Local (you, this repo) |
|---|---|---|---|
| Settings | ~/.claude/settings.json |
.claude/settings.json |
.claude/settings.local.json |
| MCP servers | ~/.claude.json |
.mcp.json |
~/.claude.json (per-project path) |
| Skills | ~/.claude/skills/<name>/SKILL.md |
.claude/skills/<name>/SKILL.md |
-- |
| Agents | ~/.claude/agents/<name>.md |
.claude/agents/<name>.md |
-- |
| Rules | ~/.claude/rules/*.md |
.claude/rules/*.md |
-- |
| Instructions | ~/.claude/CLAUDE.md |
./CLAUDE.md |
-- |
| Hooks | In ~/.claude/settings.json |
In .claude/settings.json |
In .claude/settings.local.json |
| What you're adding | Best scope | Why |
|---|---|---|
| Figma MCP, Supabase MCP, shadcn MCP | Project | Everyone on the team needs the same data sources |
| Notion MCP (your personal workspace) | User | Your Notion, not the team's |
| A debug MCP on localhost:8080 | Local | Temporary, only on your machine |
Bash(pnpm *) permission |
Project | Team standard |
Bash(my-personal-script *) permission |
Local | Just you |
/generate-page skill |
Project | Team workflow |
/my-snippets skill |
User | Personal productivity |
| Code style rules | Project (.claude/rules/) |
Consistency |
| "I prefer terse responses" | User (~/.claude/CLAUDE.md) |
Personal preference |
Settings are evaluated in this order. First match wins. Deny always beats allow.
1. Managed (IT/admin, cannot be overridden)
2. Command line flags
3. Local (.claude/settings.local.json)
4. Project (.claude/settings.json)
5. User (~/.claude/settings.json)
The four layers described in The Architecture are configured across Steps 1-5 above. Here is what each layer stops and what it does not:
| Layer | Stops | Does not stop |
|---|---|---|
| Container isolation | Host filesystem access, process interference, system-level damage | Anything reachable inside the container |
| Network firewall | Exfiltration to unknown domains, arbitrary curl/wget |
Traffic to whitelisted domains (data could be sent to an allowed endpoint) |
| Filesystem mounts | Reads/writes to host home, SSH keys, other repos | Reads/writes within /workspace (your project) |
| Claude Code permissions | Specific destructive commands (rm -rf), reading .env files |
Commands not covered by deny rules |
With all four layers in place, you can run claude --dangerously-skip-permissions to eliminate permission prompts. The container IS the sandbox. This is the intended workflow for unattended and batch operations.
Caveat from Anthropic: DevContainers do not prevent a malicious project from exfiltrating anything accessible inside the container, including Claude Code credentials. Only use with trusted repositories.
The devcontainer.json in Step 2 includes three hardening measures that go beyond the Anthropic reference implementation:
| Measure | What it does | Why it matters |
|---|---|---|
--cap-drop=ALL + selective --cap-add |
Drops all Linux capabilities, then re-adds only NET_ADMIN and NET_RAW (required for iptables). |
Without this, the container retains default capabilities like CHOWN, DAC_OVERRIDE, FOWNER, SETUID, etc. -- any of which could be exploited for privilege escalation. |
--security-opt=no-new-privileges:true |
Prevents processes inside the container from gaining additional privileges via setuid binaries or capability escalation. |
Even if an attacker drops a setuid binary into /workspace, it cannot escalate to root. |
VSCODE_IPC_HOOK_CLI="", VSCODE_GIT_IPC_HANDLE="", GIT_ASKPASS="" |
Neutralizes VS Code IPC sockets exposed inside the container. | An agent with shell access could use these sockets to execute commands in the host VS Code process -- effectively escaping the sandbox. Clearing them closes this attack vector. Credit: Daniel Demmel. |
SSH keys (~/.ssh) are deliberately not mounted into the container. This means:
- The agent cannot
git pushto remotes that require SSH authentication. git commitworks normally (uses.gitconfigfor identity).- If your workflow requires the agent to push (e.g., for automated PR creation), mount the key read-only and add an explicit deny rule:
// In .claude/settings.json -- allow push only via approved patterns
"deny": ["Bash(git push --force *)"]The safest pattern is to let the agent commit locally, then have the developer (or CI) handle the push.
The init-firewall.sh script resolves domain names to IPs at container start. Two caveats:
- IP rotation: Cloud services rotate IPs. If an MCP server stops working after a container restart, rebuild the container (
Ctrl+Shift+P> "Rebuild Container") to re-resolve IPs. - Verification: The Anthropic reference implementation includes a verification step that tests blocked vs allowed domains after initialization. Add this to your script for production use.
| Limitation | Detail |
|---|---|
| MCP OAuth in containers | Some MCP servers with browser-based OAuth (e.g., Linear) may fail to complete the auth flow inside a devcontainer. Workaround: authenticate on the host first, then pass the token via remoteEnv. |
| Domain fronting | The firewall filters by IP, not by SNI/hostname. A whitelisted IP serving multiple domains could be used to reach an unintended service. This is a known limitation of IP-based filtering. |
| Shared kernel | DevContainers share the host kernel (unlike VMs or Docker Sandbox). For maximum isolation against untrusted code, consider hypervisor-level solutions. |
| Bash deny rules are fragile | Bash(rm -rf *) can be bypassed by rewriting the command (e.g., find . -delete). Deny rules are a speed bump, not a wall. The container boundary is the real enforcement layer. |
| Concern | Before (bare metal) | After (secure devcontainer) |
|---|---|---|
| Filesystem access | Claude reads/writes anywhere your user can | Writes only to /workspace. Host home, SSH keys, other repos invisible |
| Network access | Unrestricted. Can curl anything | Default-deny. Only Anthropic API, npm, GitHub, Figma, Supabase reachable |
| Permission fatigue | Approve every command, or --dangerously-skip-permissions on bare metal (risky) |
--dangerously-skip-permissions safely. The container is the sandbox |
| Team consistency | "Works on my machine." Different Node, pnpm, CLI versions | .devcontainer/ committed. Clone + Reopen = identical for everyone |
| Onboarding | Install Node, pnpm, supabase CLI, configure auth, install extensions... | Open in VS Code. "Reopen in Container." Done |
| MCP servers | Each dev configures their own | .mcp.json committed. Same data sources for everyone |
| Skills & workflows | Knowledge lives in one person's head | .claude/skills/ committed. /generate-page works for everyone |
| Credential isolation | ~/.env, ~/.aws, ~/.ssh all accessible |
Only explicitly passed env vars are visible |
| Blast radius | rm -rf / destroys your actual files |
rm -rf / destroys the container. Rebuild in 30 seconds |
| Scope confusion | Claude asks "user or project?" and you guess | Clear decision tree. Team stuff = project. Personal = user. Temp = local |
| CI/CD parity | Dev and CI run on different environments | CI uses the same Dockerfile |
| Auditability | No visibility into what Claude did | Git history, container logs, hooks can log every tool call |
| I want to add... | How | Where it goes |
|---|---|---|
| A CLI (supabase, convex, pnpm) | RUN npm install -g <pkg> in Dockerfile. Rebuild container. |
.devcontainer/Dockerfile |
| A CLI that doesn't need global install (shadcn) | Just use npx or pnpm dlx at runtime. Nothing to install. |
-- |
| An MCP server for the team | claude mcp add --scope project --transport http <name> <url> |
.mcp.json |
| An MCP server just for me | claude mcp add --transport http <name> <url> (default = local scope) |
~/.claude.json |
| A permission rule for the team | Edit .claude/settings.json > permissions.allow or permissions.deny |
.claude/settings.json |
| A custom slash command | Create .claude/skills/<name>/SKILL.md |
.claude/skills/ |
| A personal slash command | Create ~/.claude/skills/<name>/SKILL.md |
~/.claude/skills/ |
| Project instructions | Edit CLAUDE.md at project root |
./CLAUDE.md |
| Personal instructions | Edit ~/.claude/CLAUDE.md |
~/.claude/CLAUDE.md |
| A code style rule | Create .claude/rules/<topic>.md |
.claude/rules/ |
| A hook (e.g. lint on save) | Add to .claude/settings.json > hooks |
.claude/settings.json |
| A whitelisted domain | Add to init-firewall.sh > whitelist <domain>. Rebuild container. |
.devcontainer/init-firewall.sh |
| A VS Code extension | Add to devcontainer.json > customizations.vscode.extensions |
.devcontainer/devcontainer.json |
| A forwarded port (e.g. 54321 for Supabase Studio) | Add to devcontainer.json > forwardPorts |
.devcontainer/devcontainer.json |
| An env var from host | Add to devcontainer.json > remoteEnv as ${localEnv:VAR_NAME} |
.devcontainer/devcontainer.json |
The devcontainer gives you CI/CD parity -- the same Dockerfile runs locally and in CI. But CI pipelines run non-interactively, so you need headless authentication.
# In CI, pass the API key as an environment variable
export ANTHROPIC_API_KEY=${{ secrets.ANTHROPIC_API_KEY }}
# Or use an OAuth token for Claude Pro/Team accounts
export CLAUDE_CODE_OAUTH_TOKEN=${{ secrets.CLAUDE_OAUTH_TOKEN }}
# Run Claude Code in non-interactive mode
claude -p "run all tests and report failures" --dangerously-skip-permissions --max-turns 10For MCP servers that require OAuth (Figma, Supabase), generate long-lived tokens and pass them via remoteEnv or CI secrets rather than relying on browser-based auth flows.
-
Put
.devcontainer/inside your repo. It's the only way to guarantee consistency, support Codespaces, and keep config under code review. -
Wide read, narrow write. MCP servers and CLIs provide read-only context. The container restricts writes to your project folder.
-
Four layers of defense. Container isolation, network firewall, filesystem mounts, Claude Code permissions. No single layer is enough.
-
The container replaces permission fatigue. Use
--dangerously-skip-permissionssafely inside it. -
Scope = audience. Team stuff goes in project scope (
.claude/settings.json,.mcp.json). Personal stuff goes in user scope (~/.claude/). Temporary stuff goes in local scope (.claude/settings.local.json). -
CLIs go in the Dockerfile. MCP servers go in
.mcp.json. Skills go in.claude/skills/. Three different things, three different places, one clear pattern.
This guide focuses on team workflow and the Claude Code ecosystem. For deeper dives into specific areas:
| Topic | Resource | Why read it |
|---|---|---|
| Low-level container hardening | Daniel Demmel: Coding Agents in Secured VS Code Dev Containers | IPC socket hardening, Docker socket proxy, privilege escalation prevention. The deepest security treatment in the field. Complementary to this guide. |
| Security audit tooling | Trail of Bits: claude-code-devcontainer | Production-grade CLI (devc), session analytics, headless OAuth, multi-client deployment patterns. Built by a security firm. |
| Hypervisor-level isolation | Docker Sandbox | Separate kernel, stronger than devcontainers. For teams reviewing untrusted code or needing VM-grade isolation without the VM overhead. |
| Threat modeling for AI agents | NVIDIA: Practical Security Guidance for Sandboxing Agentic Workflows | Framework for reasoning about agent sandboxing. Covers the "lethal trifecta" (LLM + code execution + untrusted content). |
| Safely running agents (tutorial) | Andrea Bizzotto: How to Safely Run AI Agents Inside a DevContainer | Accessible introduction. Good framing of "shift from supervisor to architect." |
| MCP security | CoSAI: Practical Guide to MCP Security | OAuth risks, confused deputy problem, SSRF via MCP metadata discovery. |
Claude Code documentation
- Devcontainer -- Security -- Sandboxing -- Permissions -- Settings -- MCP -- Memory & CLAUDE.md -- Custom Commands
Anthropic reference implementations
MCP servers
- Figma -- Supabase -- Convex -- shadcn -- Playwright -- Context7 -- MCP Protocol
DevContainers and Docker
- VS Code Dev Containers -- Dev Container Spec -- Docker Bind Mounts -- Microsoft Engineering Playbook
Security references
{ "features": { "ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {} } }