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](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