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/dnsmasqrules enforced insideclaude-codeitself; see Egress control below. - gateway-client mode —
claude-codetunnels all outbound traffic over SSH to a separateagent-gatewaycontainer, which owns the allowlist instead. This is a stronger isolation guarantee: a compromisedclaude-codecontainer 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:
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-scoped —
uv venv .venv && uv pip install <package>inside/workspace/<project>(oruv add <package>in auv-managed project). Lives in the project's own directory, persists via the project bind mount, isolated per project. - Python, ad hoc —
uv venv /tmp/<name>(oruv run --with <package> ...). Lives on the/tmptmpfs, isolated per task, wiped when the container exits. - Node, project-scoped —
npm install <package>inside/workspace/<project>, writing to that project's ownnode_modules— works the same way it always has. - Node, global —
npm install -g <package>installs under/home/claude/.npm-global, which is onPATHand persists via theclaude-homevolume.
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 asclaude-plugins-official.plugin-marketplaces.txt— additional plugin marketplaces to add before installing plugins fromplugins.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.txtrunsnpm install -g.tools-uv.txtrunsuv tool install, one isolated venv per entry, for standalone Python CLI tools (e.g.ruff) — only that entry's own console-script ends up onPATH, nothing importable lands anywhere shared.packages-uv.txtrunsuv pip install --systeminto 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— samplesettings.jsonfor 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 norAGENT_ALLOWED_EGRESSis 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.