This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This repository builds a secure Podman-based container for running Claude Code. The container is a minimal NixOS 25.11 image with Claude Code running as the host user (rootless Podman maps container UID 0 to the host user on disk).
| File | Role |
|---|---|
flake.nix |
Defines packages.container (image build) and dev shell |
claude-env.yaml |
Podman kube file — volumes, security context |
nix build # produces ./result (OCI tar); .#container also works
podman load < result # imports as localhost/claude-env:latestpackages.default / packages.container are only available on Linux.
One-time setup:
mkdir -p claude/.claude
cp ~/.claude/.credentials.json claude/.claude/.credentials.jsonBefore starting, generate the dev environment script on the host. gen-dev-env.sh takes two arguments — the flake and the project directory — so the flake providing the dev shell need not live inside the project (one shared flake can serve many projects):
# gen-dev-env.sh FLAKE_PATH PROJECT_DIR
./gen-dev-env.sh examples/rust examples/rust # flake and project coincide
./gen-dev-env.sh /path/to/shared-flake /path/to/proj # one shared flake, any projectdev-env.sh is written into PROJECT_DIR, so it appears in the container at /work/POD_NAME/dev-env.sh.
Start a pod (pod name defaults to the last path component of the work directory):
./play.sh examples/rust # pod named "rust", mounted at /work/rust
./play.sh /path/to/project mypod # explicit pod name, mounted at /work/mypodThe project is mounted at /work/POD_NAME (not bare /work), giving each project a distinct in-container path and therefore a distinct Claude Code session slug.
Enter the container — sources /work/POD_NAME/dev-env.sh, cds to /work/POD_NAME, drops to an interactive shell:
./exec.sh rust # pod name matches what was passed to play.shLaunch claude from /work/POD_NAME (where exec.sh leaves you), not a subdirectory: Claude Code keys session history to the launch directory, so cd-ing deeper before running claude forks the session bucket and breaks claude --resume.
Tear down:
podman pod stop rust && podman pod rm rustMultiple pods with different projects can run simultaneously; each has its own name, its own /work/POD_NAME mount, and its own dev-env.sh. Project files are isolated by the mount; session history is shared in the one home but scoped per project on resume (see Architecture).
packages.container (flake.nix):
pkgs.buildEnv merges all tools into a single environment at the image root. Static /etc/passwd, /etc/group, /etc/resolv.conf, /etc/hosts, and /etc/hostname are created via pkgs.writeTextDir and passed as separate items in contents — no fakeRootCommands or fakeroot required. /home/claude/.claude is pre-created via extraCommands. pkgs.cacert provides the CA bundle; SSL_CERT_FILE and NIX_SSL_CERT_FILE point at it so Node.js (Claude Code's OAuth token refresh) can verify TLS.
UID mapping: Container UID 0 maps to the host user in rootless Podman. No keep-id annotation is needed. Files written inside the container appear owned by the host user.
Volume layout:
| Container path | Source | Purpose |
|---|---|---|
/home/claude |
./claude/ |
Claude Code state (credentials, settings, history) |
/work/POD_NAME |
host path passed to play.sh |
Development project; also contains dev-env.sh |
/nix/store |
/nix/store (host, read-only) |
Pre-built derivations referenced by dev-env.sh |
/nix/var/nix/profiles |
host, read-only | Nix profile binaries |
No daemon socket is mounted. The container can read the host store but cannot build, delete, or GC. The entire claude/ directory is .gitignored.
Credentials: OAuth credentials live in claude/.claude/.credentials.json (copied from ~/.claude/.credentials.json on first setup).
Session model (shared store, per-project scope): CLAUDE_CONFIG_DIR is left at its default, so Claude Code's config root is the shared home at /home/claude/.claude. Account state (credentials, global settings such as the status line) and all session history therefore live in one place, shared across every pod started from this clone. Isolation between projects comes from two independent mechanisms:
- Project files are isolated by the mount — a pod sees only its own
/work/POD_NAME, never another project's directory. - Sessions are isolated by the path slug. Because each project is mounted at a distinct
/work/POD_NAMEandclaudeis launched from there, Claude Code files each project's transcripts under a distinct slug in/home/claude/.claude/projects/.claude --resumefrom/work/POD_NAMElists only that project's sessions, yet all transcripts remain in the single shared home for browsing or backup.
This is why the mount path is /work/POD_NAME rather than bare /work: mounting every project at the same path would collapse them into one slug and let --resume mix sessions across projects. To keep work and personal accounts separate, clone this repository twice and seed each clone's claude/.claude/ with the matching credentials.
Sandbox: enableWeakerNestedSandbox: true is set in claude/.claude/settings.json because bubblewrap's nested user-namespace setup hangs inside a rootless Podman container.
The container mounts the host Nix store and profiles read-only. No daemon socket is mounted. Dev shell environments are injected via gen-dev-env.sh / exec.sh (the nix print-dev-env approach). Two alternative approaches and one upgrade are documented below.
nix print-dev-env (Nix ≥ 2.4) evaluates a flake's devShell on the host and writes a shell script (dev-env.sh) that sets PATH, CC, PKG_CONFIG_PATH, etc. to the relevant /nix/store/... paths. Since the store is read-only mounted, those paths are accessible without a daemon socket.
gen-dev-env.shrunsnix print-dev-envand writesdev-env.shinto the project directory (its second argument), independent of where the flake lives.exec.shsourcesdev-env.shinside the container viabash -c "source /work/POD_NAME/dev-env.sh && cd /work/POD_NAME && exec bash -i".- Re-run
gen-dev-env.shwhenever the flake's inputs change. - Only works for packages already in the host store; run
nix developon the host first if the flake has new deps.
Mount the daemon socket at a non-standard container path (e.g. /run/host-nix.sock) instead of the default /nix/var/nix/daemon-socket/. Nix tools won't find it by default and fall back to local mode (which fails because /nix/store is read-only), so all Nix operations fail silently unless the user explicitly sets NIX_REMOTE:
NIX_REMOTE=unix:///run/host-nix.sock nix developDestructive commands require the same flag — friction, not isolation. In claude-env.yaml:
volumeMounts:
- name: nix-daemon-socket
mountPath: /run/host-nix.sockComposable with the current approach: no socket by default, socket available at a known path as an escape hatch.
The strongest isolation. The container gets its own writable /nix Podman volume and a local Nix daemon; the host daemon socket is mounted read-only at a non-standard path purely as a binary cache.
- Container
/nixvolume — a named Podman volume, completely independent of the host store. - Container-local Nix daemon — add
pkgs.nixto the image; runnix-daemon &at startup via an entrypoint script. - Host socket at
/run/host-nix.sock— read-only bind mount; never the default daemon. daemon://substituter — in the container's/etc/nix/nix.conf:The container daemon fetches pre-built NARs from the host on cache miss; most derivations are already present, so network traffic is minimal.extra-substituters = daemon:///run/host-nix.sock
Trade-offs:
Current (print-dev-env) |
Option A (non-standard socket) | Option B (container store) | |
|---|---|---|---|
| Setup complexity | gen-dev-env.sh step |
Minimal YAML change | Entrypoint script + nix-daemon in image |
| Host store isolation | Complete (no socket) | Friction only | Complete |
| Disk usage | Zero extra | Zero extra | Duplicates container builds |
nix develop inside container |
No (env injected externally) | With NIX_REMOTE= |
Yes |
| Host socket exposure | None | Non-default path | Read-only, non-default path |
Option B is not currently implemented but is the natural next step if full store isolation with in-container nix develop is required.
Enter the dev shell for working on this repo:
nix developUpdate pinned inputs:
nix flake update