Skip to content

The claude-code container

claude-code packages Claude Code — the command-line coding agent — inside a hardened container: non-root user, read-only root filesystem, a minimal Linux capability set, and outbound network access denied by default. Use it any time you want Claude Code to operate with real filesystem and shell access on a project without extending that access, or Claude's own network reachability, to the rest of the host.

Two mechanisms are available for controlling what the container can reach over the network:

  • an in-container allowlist (the default) — iptables/ipset/dnsmasq rules enforced inside claude-code itself; see Egress control below.
  • gateway-client modeclaude-code tunnels all outbound traffic over SSH to a separate agent-gateway container, which owns the allowlist instead. This is a stronger isolation guarantee: a compromised claude-code container has no access to the rules governing its own egress, since those rules live in a different container (or on a different machine entirely). See Gateway-client mode below.

Gateway-client mode is opt-in and additive — it changes nothing about the default, standalone deployment described first.

Build

The image's build context needs agent-images/shared supplied as a named Buildx build context, since Dockerfile pulls the shared egress-allowlist script from it:

docker build --build-context shared=agent-images/shared \
  -t claude-code agent-images/claude-code

Matching your host user's UID/GID

The image defaults to UID/GID 1000:1000. Everything the agent writes to the bind-mounted project directory or the claude-home volume is owned by whatever UID/GID the container ran as — if that doesn't match your host account, those files aren't owned by you on the host (permission errors, needing sudo to clean up). Rebuild with matching values instead:

docker build --build-context shared=agent-images/shared \
  --build-arg UID=$(id -u) --build-arg GID=$(id -g) \
  -t claude-code:$(whoami) agent-images/claude-code

Dockerfile splits into a base stage that installs everything — apt/Node.js/uv baseline, packages-apt.txt/packages-uv.txt, Claude Code itself, and the account-scoped packages-npm.txt/tools-uv.txt/plugin- marketplace/plugin installs — under a fixed placeholder account (UID/GID 1000:1000), and a final stage that only remaps that account to the UID/GID build args (usermod/groupmod plus a chown -R of what base already installed) when they differ from the placeholder. Nothing in final reinstalls packages or plugins, so rebuilding for a different UID after the first build is fast even though the default plugins.txt installs thirteen plugins.

On a host shared by multiple accounts, tag the image per user (claude-code:alice, claude-code:bob) rather than overwriting a shared claude-code tag — and use that same tag, plus a per-user home volume (claude-home-alice rather than the shared claude-home), in the run command below. The shell-shortcut functions do both substitutions automatically from a single CONTAINED_CLAUDE_TAG variable; if invoking docker run directly instead, substitute your tag into both the claude-home volume name and the trailing claude-code image reference in the command below — copying it unchanged still runs the shared claude-code:latest.

Run

docker run -it --rm \
  --security-opt=no-new-privileges \
  --read-only \
  --tmpfs /tmp \
  --tmpfs /run \
  --cap-drop=ALL --cap-add=NET_ADMIN --cap-add=NET_RAW --cap-add=SETUID --cap-add=SETGID \
  -v claude-home:/home/claude \
  -v "$PWD":"/workspace/$(basename "$PWD")" \
  -w "/workspace/$(basename "$PWD")" \
  claude-code

Hardening flags

Flag Purpose
--security-opt=no-new-privileges Blocks privilege escalation via setuid binaries.
--read-only Makes the root filesystem immutable.
--tmpfs /tmp Writable scratch space for dnsmasq's runtime config/pid files and iptables' lock file. /workspace and /home/claude stay writable regardless — mounts are independent of the root filesystem's read-only flag.
--tmpfs /run Writable scratch space for process-runtime files.
--cap-drop=ALL Strips Docker's full default capability set.
--cap-add=NET_ADMIN --cap-add=NET_RAW Required in every configuration (even with no allowlist set) — the entrypoint always installs a default-deny iptables/ip6tables policy. NET_RAW is additionally needed once a domain-based allowlist is in play, since matching rules against the resolved-IP ipset needs it.
--cap-add=SETUID --cap-add=SETGID Needed for gosu to drop from root to the unprivileged claude user after entrypoint setup.

Persistent state

/home/claude is mounted from a named volume (claude-home) so plugins, settings, Claude's own project memory, and any Python/Node package state persist across container runs instead of being lost when the container is removed. That volume is shared by every invocation of this image, and Claude Code keys its per-project memory/session data off the working directory's path — so if every project were mounted at the same /workspace path, unrelated projects would collide inside that shared volume. Mounting each project under its own /workspace/<project_name> subdirectory (and setting -w to match) keeps them distinct.

Volumes created before this mount point widened from /home/claude/.claude to /home/claude are migrated automatically on first run — see entrypoint.sh.

Migrating a pre-existing volume

If you have such a pre-existing volume, do one plain run (no extra -v/-e overrides) first to let migration complete before combining it with the settings.local-model.json mount example below — mounting that file on the very first run against an unmigrated volume forces its parent directory to be created before migration can run, which skips migration and leaves the volume's old contents at the top level instead of nested under .claude/. This does not affect brand-new volumes or volumes already migrated under this version.

Installing Python and Node packages at runtime

Claude can install packages at runtime without the rootfs needing to be writable:

  • Python, project-scopeduv venv .venv && uv pip install <package> inside /workspace/<project> (or uv add <package> in a uv-managed project). Lives in the project's own directory, persists via the project bind mount, isolated per project.
  • Python, ad hocuv venv /tmp/<name> (or uv run --with <package> ...). Lives on the /tmp tmpfs, isolated per task, wiped when the container exits.
  • Node, project-scopednpm install <package> inside /workspace/<project>, writing to that project's own node_modules — works the same way it always has.
  • Node, globalnpm install -g <package> installs under /home/claude/.npm-global, which is on PATH and persists via the claude-home volume.

uv's own package cache and any Python interpreters it downloads to satisfy a project's requires-python also live under /home/claude and persist via the same volume — repeated installs of a previously-seen package/interpreter version are instant, and different projects/tasks can depend on conflicting versions of the same package without interfering with each other, since environments (venvs, node_modules) are never shared — only the cache is.

Because that cache (under /home/claude) and a project's venv (under /workspace) sit on different mounts, uv can never hardlink between them — the image sets UV_LINK_MODE=copy so it performs that copy directly instead of warning about a hardlink fallback on every install.

Custom configuration and optional build-time tools

  • plugins.txt — plugins to install at build time, one per line, as <plugin>@<marketplace>. The official Anthropic marketplace is preinstalled as claude-plugins-official.
  • plugin-marketplaces.txt — additional plugin marketplaces to add before installing plugins from plugins.txt; see the comments in the file for accepted source formats.
  • packages-apt.txt / packages-npm.txt / tools-uv.txt / packages-uv.txt — general (non-agent) software to install at build time, one package per line. packages-apt.txt (apt-get install) ships prefilled with common CLI tools (gh, jq, ripgrep, fd-find, tree, unzip, less, file, lsof, yq, miller); the rest ship empty. packages-npm.txt runs npm install -g. tools-uv.txt runs uv tool install, one isolated venv per entry, for standalone Python CLI tools (e.g. ruff) — only that entry's own console-script ends up on PATH, nothing importable lands anywhere shared. packages-uv.txt runs uv pip install --system into the image's system Python instead, for plain importable libraries with no console-script of their own (e.g. langfuse) that need to be importable by whatever runs inside the image. Edit any of the four and rebuild the image to change what's installed.
  • examples/settings.local-model.json — sample settings.json for pointing Claude Code at a custom/local model endpoint (ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_MODEL).
  • examples/egress-allowlist.txt — sample allowlist of hosts/IPs the container is allowed to reach outbound; defaults to deny-all if neither this file nor AGENT_ALLOWED_EGRESS is set, or set to * for unrestricted egress.

Model settings and the egress allowlist can each be supplied either by mounting a file into the container or by setting environment variables directly with -e.

For the corresponding file paths and mount behaviour for every image, see custom configuration files. Claude Code's global settings file is /home/claude/.claude/settings.json.

docker run -it --rm \
  --security-opt=no-new-privileges \
  --read-only \
  --tmpfs /tmp \
  --tmpfs /run \
  --cap-drop=ALL --cap-add=NET_ADMIN --cap-add=NET_RAW --cap-add=SETUID --cap-add=SETGID \
  -v claude-home:/home/claude \
  -v "$PWD":"/workspace/$(basename "$PWD")" \
  -w "/workspace/$(basename "$PWD")" \
  -v "$PWD/agent-images/claude-code/examples/settings.local-model.json":/home/claude/.claude/settings.json:ro \
  claude-code

Mounted :ro since this file is bind-mounted directly over whatever settings.json already exists in the claude-home volume — it shadows that file for the run rather than merging with it, and mounting read-write would mean any settings Claude Code writes back land on your host's checked-in settings.local-model.json instead of in the volume. If you want local-model settings to coexist with whatever else lives in the volume's settings.json, prefer the environment-variable form instead.

docker run -it --rm \
  --security-opt=no-new-privileges \
  --read-only \
  --tmpfs /tmp \
  --tmpfs /run \
  --cap-drop=ALL --cap-add=NET_ADMIN --cap-add=NET_RAW --cap-add=SETUID --cap-add=SETGID \
  -v claude-home:/home/claude \
  -v "$PWD":"/workspace/$(basename "$PWD")" \
  -w "/workspace/$(basename "$PWD")" \
  -e ANTHROPIC_BASE_URL=https://your-local-model.example.com \
  -e ANTHROPIC_AUTH_TOKEN=replace-with-your-token \
  -e ANTHROPIC_MODEL=your-local-model-name \
  claude-code

To keep the container fully static/offline (no background update checks), set DISABLE_AUTOUPDATER=1 as an environment variable or mount a file containing this setting. Updates require outbound network access to the marketplace source — relevant given the image's egress allowlist (api.anthropic.com etc. would need to be reachable, or marketplace hosts allowed too).

Egress control

In-container allowlist

The default mode. entrypoint.sh enforces AGENT_ALLOWED_EGRESS (or a mounted /etc/agent/egress-allowlist.txt, which takes precedence if both are supplied) against the container's own OUTPUT chain, defaulting to deny-all if neither is set.

docker run -it --rm \
  --security-opt=no-new-privileges \
  --read-only \
  --tmpfs /tmp \
  --tmpfs /run \
  --cap-drop=ALL --cap-add=NET_ADMIN --cap-add=NET_RAW --cap-add=SETUID --cap-add=SETGID \
  -v claude-home:/home/claude \
  -v "$PWD":"/workspace/$(basename "$PWD")" \
  -w "/workspace/$(basename "$PWD")" \
  -v "$PWD/agent-images/claude-code/examples/egress-allowlist.txt":/etc/agent/egress-allowlist.txt:ro \
  claude-code
docker run -it --rm \
  --security-opt=no-new-privileges \
  --read-only \
  --tmpfs /tmp \
  --tmpfs /run \
  --cap-drop=ALL --cap-add=NET_ADMIN --cap-add=NET_RAW --cap-add=SETUID --cap-add=SETGID \
  -v claude-home:/home/claude \
  -v "$PWD":"/workspace/$(basename "$PWD")" \
  -w "/workspace/$(basename "$PWD")" \
  -e 'AGENT_ALLOWED_EGRESS=api.anthropic.com,your-local-model.example.com' \
  claude-code

For unrestricted egress, use -e 'AGENT_ALLOWED_EGRESS=*'. Quoting prevents shells such as zsh from treating * as a filename pattern.

Gateway-client mode

Setting AGENT_GATEWAY_HOST switches claude-code from the in-container allowlist to gateway-client mode: instead of filtering its own traffic, claude-code runs sshuttle to tunnel all outbound traffic (including DNS) to an agent-gateway container, which enforces the allowlist on the workload's behalf. AGENT_ALLOWED_EGRESS/egress-allowlist.txt are ignored (with a logged warning) when AGENT_GATEWAY_HOST is set — the allowlist that matters in this mode is the one configured on the gateway itself.

Why tunnel instead of filtering locally?

A compromised claude-code container running the in-container allowlist still holds NET_ADMIN and the iptables rules constraining its own egress — a sufficiently capable compromise could rewrite them. In gateway-client mode, those rules live in a separate container (or on a separate machine), which the workload has no access to.

Configuration

Variable / mount Purpose
AGENT_GATEWAY_HOST Gateway's address (Docker network address, public IP/hostname). Enables gateway-client mode when set; unset falls back to the in-container allowlist.
AGENT_GATEWAY_PORT Gateway's SSH port. Defaults to 2222.
AGENT_GATEWAY_USER SSH user on the gateway. Defaults to tunnel.
AGENT_GATEWAY_BOOTSTRAP_ALLOW Comma-separated bare IPs/CIDRs (never a hostname) the entrypoint allows before the tunnel comes up — narrowly scoped to the gateway's own address, so egress isn't wide open during the brief window before sshuttle takes over.
AGENT_GATEWAY_ACCESS_HOSTNAME Optional. Reach the gateway via Cloudflare Tunnel instead of direct TCP — see Cloudflare Tunnel below.
-v ./gateway-key:/etc/agent/gateway-key:ro SSH private key for the tunnel.
-v ./gateway-known-hosts:/etc/agent/gateway-known-hosts:ro Pinned gateway host key, so StrictHostKeyChecking=yes works non-interactively on the first connection.

No additional capabilities are required beyond the defaults above — sshuttle uses the same NET_ADMIN/NET_RAW grant that the in-container allowlist uses, just to install redirect rules instead of filter rules.

Example: same-host sibling gateway

docker network create agent-net
ssh-keygen -t ed25519 -f gateway-key -N "" -C "agent-gateway"

# Start the gateway first — see 00-agent-gateway.md for the full walkthrough.
docker run -d --name agent-gateway --network agent-net \
  --cap-drop=ALL --cap-add=NET_ADMIN --cap-add=NET_RAW \
  --cap-add=SETUID --cap-add=SETGID --cap-add=SYS_CHROOT \
  -e 'AGENT_ALLOWED_EGRESS=github.com,pypi.org' \
  -v ./gateway-key.pub:/etc/agent/gateway-key.pub:ro \
  -v agent-gateway-hostkey:/etc/ssh/keys \
  agent-gateway

# Scan from another container on agent-net, not from the Docker host — see
# 00-agent-gateway.md's "Pin the gateway's host key" note for why.
docker run --rm --network agent-net alpine:3 sh -c \
  "apk add --no-cache openssh-client >/dev/null && ssh-keyscan -p 2222 agent-gateway" \
  > gateway-known-hosts
GW_IP=$(docker inspect agent-gateway --format '{{ (index .NetworkSettings.Networks "agent-net").IPAddress }}')

docker run -it --rm --network agent-net \
  --cap-drop=ALL --cap-add=NET_ADMIN --cap-add=NET_RAW --cap-add=SETUID --cap-add=SETGID \
  --security-opt=no-new-privileges --read-only --tmpfs /tmp --tmpfs /run \
  -e AGENT_GATEWAY_HOST=agent-gateway \
  -e AGENT_GATEWAY_BOOTSTRAP_ALLOW="$GW_IP" \
  -v ./gateway-key:/etc/agent/gateway-key:ro \
  -v ./gateway-known-hosts:/etc/agent/gateway-known-hosts:ro \
  -v claude-home:/home/claude \
  -v "$PWD":"/workspace/$(basename "$PWD")" \
  -w "/workspace/$(basename "$PWD")" \
  claude-code

Example: genuinely remote gateway

Isolation that survives a full compromise of the machine running claude-code, not just the container — the gateway runs on separate infrastructure you control:

# on the workload host
docker run -it --rm \
  --cap-drop=ALL --cap-add=NET_ADMIN --cap-add=NET_RAW --cap-add=SETUID --cap-add=SETGID \
  --security-opt=no-new-privileges --read-only --tmpfs /tmp --tmpfs /run \
  -e AGENT_GATEWAY_HOST=<gateway-public-ip-or-hostname> \
  -e AGENT_GATEWAY_BOOTSTRAP_ALLOW=<gateway-public-ip> \
  -v ./gateway-key:/etc/agent/gateway-key:ro \
  -v ./gateway-known-hosts:/etc/agent/gateway-known-hosts:ro \
  -v claude-home:/home/claude \
  -v "$PWD":"/workspace/$(basename "$PWD")" \
  -w "/workspace/$(basename "$PWD")" \
  claude-code

See agent-gateway for how to run the gateway side of either example.

Cloudflare Tunnel

Both examples above reach the gateway by direct TCP. To reach it with no inbound port open at all instead, set AGENT_GATEWAY_ACCESS_HOSTNAME (the Cloudflare Access hostname) instead of relying on AGENT_GATEWAY_HOST being directly reachable — AGENT_GATEWAY_HOST still needs to be set (sshuttle's -r target), but reachability now goes through Cloudflare's edge rather than straight to that address:

docker run -it --rm \
  --cap-drop=ALL --cap-add=NET_ADMIN --cap-add=NET_RAW --cap-add=SETUID --cap-add=SETGID \
  --security-opt=no-new-privileges --read-only --tmpfs /tmp --tmpfs /run \
  -e AGENT_GATEWAY_HOST=localhost \
  -e AGENT_GATEWAY_ACCESS_HOSTNAME=gateway.example.com \
  -v ./gateway-key:/etc/agent/gateway-key:ro \
  -v ./gateway-known-hosts:/etc/agent/gateway-known-hosts:ro \
  -v claude-home:/home/claude \
  -v "$PWD":"/workspace/$(basename "$PWD")" \
  -w "/workspace/$(basename "$PWD")" \
  claude-code

This needs the one-time Cloudflare account/tunnel/Access setup described in agent-gateway's Cloudflare Tunnel section first. AGENT_GATEWAY_BOOTSTRAP_ALLOW still applies (the entrypoint doesn't know which reachability option you're using), but its target changes: since the workload never contacts the gateway's own address directly in this mode, set it to Cloudflare's published edge IP ranges instead of a gateway IP — that's what cloudflared itself needs to reach before the tunnel is up.