Sandboxed Claude
Create a Podman dev container where Claude Code runs inside a controlled, reproducible environment. The container bind-mounts the project source, caches dependencies in named volumes, and persists Claude's own configuration across restarts. One script launches the whole thing.
See reference.md for annotated Containerfile and dev.sh templates.
Why Sandbox
Running Claude Code inside a container gives you three things:
- Reproducibility. Every collaborator gets the same toolchain -- same Node, same JDK, same Maven -- without polluting their host. No "works on my machine."
- Isolation. Claude operates within the container's filesystem boundaries. It can read and write the project (bind-mounted) but nothing else on the host.
- Speed. Named Podman volumes store dependency trees (node_modules, .m2/repository) as Linux-native filesystems, avoiding the overhead of macOS filesystem translation on every file read.
The Two-File Pattern
Every sandboxed-claude setup produces exactly two files:
infra/devcontainer/
├── Containerfile # Image definition: base + runtimes + tools + non-root user
└── dev.sh # Launch script: volumes, mounts, container lifecycle
The Containerfile builds once and is reused. dev.sh handles everything else: creating volumes, building the image on first run, tearing down stale containers, and starting a fresh interactive session.
Workflow
1. Detect the Tech Stack
Read the project to identify what runtimes and tools are needed:
| Signal | Runtime to Install |
|---|---|
package.json | Node.js (check engines field or .nvmrc for version) |
pom.xml | Java JDK + Maven |
build.gradle / build.gradle.kts | Java JDK + Gradle |
go.mod | Go |
Cargo.toml | Rust toolchain |
requirements.txt / pyproject.toml | Python |
angular.json | Node.js + Angular CLI (match project version from devDependencies) |
Most projects need one or two runtimes. A fullstack app typically needs Node + a backend runtime.
2. Choose the Base Image
Pick the base that gives you the most for free:
| Primary Runtime | Base Image | Why |
|---|---|---|
| Node.js | node:22-bookworm | Node pre-installed, Debian for easy apt-get of other tools |
| Python | python:3.12-bookworm | Python pre-installed, Debian base |
| Go | golang:1.22-bookworm | Go pre-installed, Debian base |
| Java only | eclipse-temurin:21-jdk-bookworm | JDK pre-installed |
| Rust | rust:1.78-bookworm | Rust toolchain pre-installed |
Use -bookworm (Debian 12) variants. They have apt-get for installing additional tools.
Alpine images are smaller but cause friction with native dependencies and missing shared libraries.
3. Write the Containerfile
The Containerfile follows a fixed structure. Each section has a clear purpose:
Base image (primary runtime)
→ System dependencies (curl, git, ssh, ca-certificates, jq)
→ Secondary runtimes (JDK, Maven, etc.)
→ CLI tools (Angular CLI, Claude Code)
→ Non-root user setup
→ Working directory + config directories
Principles:
- Pin versions. Use build ARGs for runtime versions so they're visible and changeable at the top of the file. Pin CLI tools to the version matching the project.
- Clean apt caches. End every
apt-get installwith&& rm -rf /var/lib/apt/lists/*to keep the image small. - Non-root user. Create a
devuser and switch to it before setting up workspace directories. Claude Code should never run as root inside the container. - Prepare mount points. Create
/home/dev/.claudeand/home/dev/.ssh(if needed) with correct permissions before the user runs anything.
4. Write dev.sh
dev.sh is the single entry point. It handles the full container lifecycle:
#!/usr/bin/env bash
set -euo pipefail
The script does five things in order:
- Resolve paths. Compute PROJECT_ROOT relative to the script location so it works from any working directory.
- Create named volumes. Use
podman volume exists || podman volume createfor each dependency cache. Named volumes are Linux-native filesystems inside the Podman VM -- they bypass macOS filesystem translation entirely. - Build the image. Only if it doesn't already exist (
podman image exists). Rebuilding is explicit: the user deletes the image when they want a fresh build. - Remove stale container.
podman rm -fthe previous container if it exists. Containers are ephemeral; volumes persist the state that matters. - Launch.
podman run -itwith all mounts and flags.
5. Configure Mounts and Volumes
This is where the decisions matter. There are three categories:
Bind mounts (source code):
-v "$PROJECT_ROOT:/workspace:z"
The entire project tree, read-write. The :z suffix is required for SELinux relabeling on
Podman (harmless on systems without SELinux).
Named volumes (dependency caches):
-v "myproject-node-modules:/workspace/app/node_modules:z"
-v "myproject-m2-repo:/home/dev/.m2/repository:z"
Each dependency tree gets its own named volume. This serves two purposes: dependencies install fast (native filesystem, not macOS FUSE), and they persist across container restarts without re-downloading.
Mount the volume at the exact path where the package manager writes dependencies:
- npm/pnpm:
<project>/node_modules - Maven:
~/.m2/repository - Gradle:
~/.gradle/caches - Go:
/home/dev/go/pkg/mod - Cargo:
/home/dev/.cargo/registry - pip: varies (use a venv path)
Claude config (persistence):
-v "$HOME/.claude-container/myproject:/home/dev/.claude:z"
Store Claude's auth tokens, settings, and conversation history in a host directory. This
survives container rebuilds. Keep it per-project under ~/.claude-container/ so different
projects don't collide.
6. Essential Flags
podman run -it \
--name myproject-dev \
--hostname myproject-dev \
--userns keep-id \
...
--userns keep-id: Maps the container'sdevuser to your host UID. Without this, files created inside the container are owned by a different UID on the host, causing permission headaches. Required on macOS.--hostname: Sets a recognizable prompt inside the container.--name: Allowspodman rm -fby name on next launch.
7. Optional: SSH Key Mounting
If the project deploys via SSH or needs git access over SSH:
-v "$HOME/.ssh/id_ed25519_myproject:/home/dev/.ssh/id_ed25519:ro,z"
-e "SSH_KEY=/home/dev/.ssh/id_ed25519"
Mount the key read-only (:ro). If the project deploys through CI/CD (GitHub Actions, etc.),
skip this entirely.
8. Print First-Run Instructions
dev.sh should print what the user needs to do on first launch:
First run: execute inside the container:
cd /workspace/app && npm ci
cd /workspace/services && mvn dependency:go-offline
claude auth login
These commands populate the named volumes. After the first run, subsequent launches are instant.
Adaptation Checklist
When creating a sandboxed-claude setup for a new project:
- Read package.json / pom.xml / go.mod to identify runtimes and versions
- Choose base image matching the primary runtime
- Pin CLI tool versions to match the project (e.g., Angular CLI from devDependencies)
- Create one named volume per dependency cache
- Mount node_modules at the exact path (watch for monorepo structures)
- Set
--userns keep-idfor macOS compatibility - Include
claude auth loginin first-run instructions - Test: build the image, launch, run
npm ci/mvn compile, verify Claude Code works
What This Skill Does Not Cover
- Production containers. This skill is about dev environments. Production Dockerfiles are multi-stage builds optimized for size; dev containers are optimized for tooling.
- Docker Compose for services. If the projec