Firewalled devcontainer-based sandboxes for running Claude Code. Each sandbox runs as a non-root user with iptables-based network restrictions, allowing access only to required services (Anthropic API, GitHub, package registries) while blocking everything else.
| Service | Image | Docker | Go | Enabled by default |
|---|---|---|---|---|
dind |
cc-dind |
yes | no | no |
golang |
cc-golang |
no | yes | no |
golang-dind |
cc-golang-dind |
yes | yes | yes |
golang-dind is the active sandbox. dind and golang are defined in docker-compose.yaml but commented out. To
enable them, uncomment the relevant service and volume entries.
All sandboxes include: Claude Code, GitHub CLI, tmux, git, and common utilities. Go sandboxes additionally include
gopls and contribute (CLI for GitHub interactions).
These sandboxes are designed to run Claude Code with --dangerously-skip-permissions safely:
- Firewall — iptables rules allowlist only the services each sandbox needs (Anthropic API, GitHub, package registries). All other outbound traffic is blocked.
- Non-root user — Claude Code runs as
vscode, not root. It cannot install system packages, modify system files, or escalate privileges beyond the restrictedsudocommands allowed during container init. - Read-only configuration —
.claude/CLAUDE.md, agents, skills, and guidelines are synced from the host and owned by root inside the container. Thevscodeuser can read but not modify them, preventing Claude from rewriting its own instructions. - Isolated filesystem — each sandbox has its own named Docker volume. There is no access to the host filesystem or other containers.
contributeGitHub App — Claude Code authenticates with GitHub as a bot identity (ai-contributor-helper[bot]) via a GitHub App private key. No personal access token or user credentials are exposed inside the container. See https://github.com/ivanov-gv/contribute
Caveat: privileged containers. DinD sandboxes run with
privileged: true, which grants full access to the host kernel. The firewall and user restrictions still apply, but a sufficiently motivated process could escape the container. Use DinD sandboxes only when Docker-in-Docker is required.
- Docker with Compose V2
- devcontainer CLI (
npm install -g @devcontainers/cli) - A
.envfile (see Configuration)
Copy .env.example to .env and fill in the values:
cp .env.example .env| Variable | Description |
|---|---|
CLAUDE_CODE_OAUTH_TOKEN |
OAuth token for Claude Code authentication |
DOCKER_HOST |
Remote Docker host (optional; leave empty to use local Docker) |
GH_CONTRIBUTE_PRIVATE_KEY_PATH |
Path to private key for the contribute GitHub App |
GH_CONTRIBUTE_APP_ID |
App ID for the contribute GitHub App |
The .env file is gitignored. The Makefile auto-exports all variables from it into the environment.
# Build the image
make build-golang-dind
# Start the sandbox
make up
# Sync Claude configuration and install plugins
make setup
# Connect to the sandbox
make connect
# Run tests
make test
# Stop and remove persistent volumes
make purge.claude/
agents/ # Claude Code agent definitions (synced to containers)
shared/guidelines/ # Shared guidelines (synced to containers)
skills/ # Claude Code skills (synced to containers)
CLAUDE.md # Injected into each container as read-only
.devcontainer/
sandbox-init/ # Local devcontainer feature
devcontainer-feature.json # PATH, entrypoint, install ordering
install.sh # Copies sandbox-init.sh into image
sandbox-init.sh # Entrypoint: firewall init + DinD chain
dind/devcontainer.json # Docker-in-Docker sandbox config
golang/devcontainer.json # Go sandbox config
golang-dind/devcontainer.json # Go + Docker-in-Docker sandbox config
.env.example # Template for required environment variables
docker-compose.yaml # Runtime config (user, entrypoint, volumes)
Makefile # Build and management targets
test-sandbox.sh # Smoke tests run inside the sandbox
devcontainer build takes a base image (mcr.microsoft.com/devcontainers/base:bookworm) and layers devcontainer
features on top:
- firewall installs
init-firewall.sh(configures network restrictions per the firewall feature settings) - docker-in-docker installs Docker CE and
docker-init.sh(DinD sandboxes only) - sandbox-init (local feature) installs the entrypoint script and fixes the PATH
The sandbox-init.sh entrypoint runs at container start:
- Sets
.claudedirectory ownership to root with a sticky bit, preventing thevscodeuser from deleting root-owned files inside it (e.g. the injectedCLAUDE.md) - Creates
/home/vscode/workdirwith correct ownership and a sticky bit - Restricts
sudoto only the commands needed during init (firewall, docker-init, tee, touch) - Skips the Claude Code first-run onboarding prompt
- Initializes the firewall via
init-firewall.sh(configures iptables rules per the firewall feature settings) - If Docker-in-Docker is installed, chains to
docker-init.sh(startsdockerd) - Falls through to
sleep infinityto keep the container alive
The .claude/ directory in this repo contains agents, skills, shared guidelines, and a CLAUDE.md that are pushed into
running containers via make sync. These files are owned by root inside the container and made read-only, so the
vscode user cannot modify them.
make install-plugins installs Claude Code plugins from configured marketplaces into each running container.
make setup runs both sync and install-plugins.
Network access is controlled per-sandbox via the firewall feature. The feature supports two allowlisting mechanisms:
IP-range-based flags (e.g., cloudflareIps, googleCloudIps) and domain-based allowlisting via the hosts parameter.
Each sandbox allows:
- Anthropic API (for Claude Code)
- GitHub (IPs and domains)
- Debian/Ubuntu package repositories
- Claude Code update servers
Additional access per sandbox type:
| Access | dind |
golang |
golang-dind |
|---|---|---|---|
| Docker Hub registry | yes | no | yes |
| Cloudflare IPs | yes | no | yes |
| Google Cloud IPs | no | yes | yes |
| Go module proxy | no | yes | yes |
DinD sandboxes also allowlist cloudflarestorage.com and download.docker.com via the hosts parameter. Go sandboxes
allowlist proxy.golang.org, sum.golang.org, storage.googleapis.com, and golang.org.
Each sandbox has a named Docker volume mounted at /home/vscode. Files in the home directory persist across container
restarts. Use make purge to wipe volumes.
- Create
.devcontainer/<name>/devcontainer.json:
{
"name": "cc-<name>",
"image": "mcr.microsoft.com/devcontainers/base:bookworm",
"shutdownAction": "none",
"features": {
"ghcr.io/stu-bell/devcontainer-features/claude-code:0": {},
"ghcr.io/devcontainers/features/common-utils:2": {},
"ghcr.io/devcontainers/features/github-cli:1": {},
"ghcr.io/w3cj/devcontainer-features/firewall:latest": {
"githubIps": true,
"githubDomains": true,
"anthropicIps": true,
"anthropicApi": true,
"debianPackages": true,
"ubuntuPackages": true,
"claudeCode": true,
"dockerRegistry": false
},
"ghcr.io/devcontainers-extra/features/tmux-apt-get:1": {},
"../sandbox-init": {}
}
}- Add the service to
docker-compose.yaml:
<name>:
<<: *default
image: cc-<name>
cap_add:
- NET_ADMIN
volumes:
- <name>-home:/home/vscodeUse privileged: true instead of cap_add: [NET_ADMIN] if the sandbox needs Docker-in-Docker. DinD sandboxes also need
a tmpfs mount for /var/lib/docker.
- Add the volume to the
volumes:section and a build target to theMakefile.
- DinD sandboxes require
privileged: true(superset ofNET_ADMIN, socap_addis unnecessary) /var/lib/dockermust be a tmpfs mount — overlayfs-on-overlayfs is not supported- Docker state inside DinD does not persist across restarts (tmpfs is ephemeral)
- DinD sandboxes need
cloudflareIps: truein the firewall config because Docker Hub serves image layers from Cloudflare R2 (*.r2.cloudflarestorage.com) - DinD sandboxes need
download.docker.comin the firewallhostsparameter (e.g.,"hosts": "cloudflarestorage.com,download.docker.com") for Docker CE apt repository access
- Firewall granularity: IP-range-based flags like
cloudflareIpsandgoogleCloudIpsallow access to all services hosted on those networks, not just the intended ones (e.g., Docker Hub or Go proxy). Thehostsparameter provides finer-grained domain-based allowlisting but is subject to DNS rotation. Both approaches involve trade-offs. - Startup delay: Firewall initialization takes up to 60 seconds on first boot while it fetches current IP ranges for GitHub, Cloudflare, Google Cloud, etc. During this window, network access is unrestricted.
- No Dockerfile: Images are built entirely from devcontainer features. The
entrypoint,user, andcommandare set at runtime viadocker-compose.yamlbecause devcontainer features cannot bake these into the image. Thex-defaultYAML anchor keeps this DRY. - JetBrains settings: The
golangandgolang-dindconfigs include JetBrains/GoLand IDE customizations. These are ignored when running viadocker composeand only apply when connecting through a JetBrains Gateway.