Guide
Running a coding agent inside a container
Nobody puts a coding agent in a container because they love Docker. They do it because they are
tired of approving npm test for the ninth time, they know what
--dangerously-skip-permissions is called for a reason, and a container looks like the
thing that lets you have the first without the second. Put the agent somewhere it cannot hurt you,
then stop reading every prompt.
That instinct is right, and the reasoning behind it is usually wrong in one specific way. This is the setup that works, the four things that break before you get there, and an honest account of which fears a container actually removes.
Verified on 2026-08-07 against Docker 29.5.3 (build
d1c06ef, Docker Desktop, Apple Silicon) and Claude Code 2.1.224
(npm latest, installed into the image on the day of writing; the host CLI was
2.1.223). Base image node:22-bookworm-slim → Node v22.23.2, git 2.39.5. Doc
claims are from code.claude.com/docs (sandboxing, sandbox-environments,
devcontainer, permission-modes, network-config) and
docs.docker.com/engine/security, all read on 2026-08-07. Every command below was
run; outputs are pasted, not reconstructed. What we could not run is marked
untested where it appears.
What a container buys you, and what it does not
Start here, because the whole rest of the article is downstream of it. A container moves the blast radius. It does not remove it.
What you genuinely get:
- Your SSH keys, cloud credentials, browser profiles, shell history and every other repository on the machine are simply not present. Not denied by a rule someone could widen — absent from the filesystem the agent sees.
-
A wrong
rm -rf ~destroys a container home directory you can recreate withdocker run. -
A package install, a global npm write, a modified
~/.zshrc: all confined, all gone when the container exits. - Everything in the session is inside the boundary. The built-in Bash sandbox covers Bash only — per Anthropic's own docs, "MCP servers and hooks are separate processes that run unconstrained on the host." In a container they are not.
What you do not get, in descending order of how often it bites:
- Protection for the repo you mounted. This is the big one. A read-write bind
mount is your actual working tree wearing a different path. We ran
echo x > /workspace/probe.txtinside the container and the file appeared in the host directory before the command returned. Everything the agent can do to your code, it can still do. - A boundary against a determined escape. Docker's own security page is blunt: "One primary risk with running Docker containers is that the default set of capabilities and mounts given to a container may provide incomplete isolation." A container shares your kernel. Anthropic's guidance is consistent with that — for an untrusted repository it points at a virtual machine, not a container.
- Anything, if you mount the Docker socket. See below; this one is worth its own demonstration.
- Confidentiality of what the model sees. Isolation does not change what leaves for the API. Your prompts and every file the agent reads go to the provider with or without a container.
The honest summary: a container is a strong boundary against accident and a weak one against intent. Almost everything that actually goes wrong with a coding agent is an accident, which is why it is worth doing — but if the reason you are containerising is "this repo might be hostile," you want a VM.
The Dockerfile
Small on purpose. Node base because the CLI is an npm package, plus the handful of packages the
firewall in the next section needs, plus one USER line that turns out to matter more
than it looks.
FROM node:22-bookworm-slim
ARG CLAUDE_CODE_VERSION=latest
RUN apt-get update && apt-get install -y --no-install-recommends \
git ca-certificates curl jq less \
iptables ipset dnsutils aggregate sudo \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}
RUN echo 'node ALL=(root) NOPASSWD: /usr/local/bin/init-firewall.sh' > /etc/sudoers.d/firewall \
&& chmod 0440 /etc/sudoers.d/firewall
COPY init-firewall.sh /usr/local/bin/init-firewall.sh
RUN chmod +x /usr/local/bin/init-firewall.sh
USER node
RUN mkdir -p /home/node/.claude
ENV CLAUDE_CONFIG_DIR=/home/node/.claude
ENV DISABLE_AUTOUPDATER=1
WORKDIR /workspace Four lines deserve an explanation.
-
USER node. Thenodeimages ship a UID-1000nodeaccount. Without this line the agent runs as root, and then--dangerously-skip-permissions— the entire reason you built the container — refuses to start. We ran it as root to confirm the exact text:
--dangerously-skip-permissions cannot be used with root/sudo privileges for security reasons -
RUN mkdir -p /home/node/.claude, afterUSER node. This one cost us a debugging cycle, so it is in the file rather than in a footnote. Details in the credentials section. - The narrow sudoers rule. The firewall needs root; the agent must not have it. One NOPASSWD entry for one absolute path is the whole grant.
-
DISABLE_AUTOUPDATER=1. Pin the CLI version with the build arg and turn the updater off, or your reproducible image quietly stops being reproducible. Anthropic documents this pairing for exactly this reason.
Build and check what you got:
$ docker build -t agentbox:1 .
$ docker run --rm agentbox:1 bash -lc \
'echo "user: $(whoami) uid=$(id -u)"; claude --version; git --version'
user: node uid=1000
2.1.224 (Claude Code)
git version 2.39.5 Getting credentials in without baking them into the image
The tempting shortcut is a build argument. Do not. We built a throwaway image that took a token as
--build-arg, wrote it to a file and deleted the file in the same layer — the
supposedly safe pattern — and then asked Docker what it remembered:
$ docker history --no-trunc --format '{{.CreatedBy}}' leakdemo:1 | grep -i 'sk-ant\|TOKEN'
RUN |1 TOKEN=sk-ant-oat01-NOT-REAL-abc123 /bin/sh -c echo "$TOKEN" > /tmp/t && rm /tmp/t # buildkit
ENV CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-NOT-REAL-abc123
ARG TOKEN=sk-ant-oat01-NOT-REAL-abc123
$ docker inspect leakdemo:1 --format '{{json .Config.Env}}'
["PATH=...","CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-NOT-REAL-abc123"] Three copies, in an image you might push. Deleting the file did nothing; the build instruction itself is the record. That is the whole argument against build-time secrets, and it takes ten seconds to reproduce.
Two paths that work, and one platform surprise.
1 — A long-lived token at run time
$ claude setup-token # on your host; prints a long-lived token
$ echo 'CLAUDE_CODE_OAUTH_TOKEN=<the token>' > ~/.agentbox.env
$ chmod 600 ~/.agentbox.env
$ docker run --rm -it --env-file ~/.agentbox.env -v "$PWD":/workspace agentbox:1 claude claude setup-token is a real subcommand — "Set up a long-lived authentication
token (requires Claude subscription)". A run-time variable never enters the image, but be precise
about what it does do: docker inspect on the running container still prints
it. We checked:
$ docker run -d --name envprobe -e CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-NOT-REAL-runtime alpine sleep 30
$ docker inspect envprobe --format '{{json .Config.Env}}'
["CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-NOT-REAL-runtime","PATH=..."]
That is fine — it is local, ephemeral, and anyone who can run docker inspect can
already run docker run. It is not fine on a shared CI runner.
2 — Sign in once, into a named volume
Mount a volume at the config directory and point CLAUDE_CONFIG_DIR at it, so both
~/.claude and the separate .claude.json land inside the volume rather
than in a container layer that disappears:
$ docker volume create agentbox-claude-config
$ docker run --rm -it \
-v agentbox-claude-config:/home/node/.claude \
-v "$PWD":/workspace \
agentbox:1 claude # sign in once; it sticks And here is the gotcha the Dockerfile line was for. On our first attempt the volume did not exist in the image, so Docker created it empty and root-owned:
bash: /home/node/.claude/.claude.json: Permission denied
drwxr-xr-x 2 root root 4096 /home/node/.claude
Docker seeds a fresh named volume from whatever is at that path in the image, ownership included.
Create the directory after USER node and the volume inherits the right owner.
After rebuilding with that one line:
drwxr-xr-x 2 node node 4096 /home/node/.claude
write OK
# second run, same volume:
{"probe":true} This is the single most common "why can't the agent write its own config" report, and it has nothing to do with the agent.
The macOS surprise
Plenty of guides tell you to bind-mount ~/.claude/.credentials.json into the
container. On a Mac there is no such file:
$ ls -la ~/.claude/.credentials.json
ls: /Users/…/.claude/.credentials.json: No such file or directory
$ security find-generic-password -s "Claude Code-credentials"
"svce"<blob>="Claude Code-credentials"
The OAuth credential is in the login Keychain, not on disk. Mount-the-file advice silently does
nothing on macOS. Use a token or sign in inside the container. And in either case, note what
Anthropic says about the reverse direction: with
--dangerously-skip-permissions, dev containers "do not prevent a malicious project
from exfiltrating anything accessible inside the container, including the Claude Code credentials
stored in ~/.claude." Whatever you put in there is in the blast radius.
Mounting the repo, and the git identity tax
The mount is one flag. The consequences are the part worth stating plainly:
$ docker run --rm -v "$PWD":/workspace agentbox:1 \
bash -lc 'echo x > /workspace/probe.txt'
$ ls
README.md probe.txt # …on the host, immediately
So: commit or branch before an unattended run. The container protects everything
except the directory you actually care about. If that bothers you, mount read-only
(-v "$PWD":/workspace:ro) and have the agent write to a scratch path — useful
for a review pass, useless for one that is supposed to produce a diff. A middle setting worth
knowing is --read-only on the root filesystem with the workspace still writable,
which we ran:
$ docker run --rm --read-only --tmpfs /tmp -v "$PWD":/workspace agentbox:1 \
bash -lc 'echo x > /workspace/probe.txt && echo "workspace write OK"; echo y > /usr/local/probe'
workspace write OK
bash: /usr/local/probe: Read-only file system Then git. Two failures show up in the first five minutes, and neither is obvious from the error.
No identity. A fresh container has no ~/.gitconfig, so the agent's
first commit dies:
$ docker run --rm -v "$PWD":/workspace agentbox:1 git commit --allow-empty -m probe
Author identity unknown
*** Please tell me who you are.
Pass it as environment rather than mounting your whole gitconfig — your real
~/.gitconfig often contains credential helpers, signing keys and URL rewrites you did
not mean to hand over:
-e GIT_AUTHOR_NAME="$(git config user.name)" \
-e GIT_AUTHOR_EMAIL="$(git config user.email)" \
-e GIT_COMMITTER_NAME="$(git config user.name)" \
-e GIT_COMMITTER_EMAIL="$(git config user.email)" Dubious ownership. Git refuses to operate on a repository owned by a different UID. Reproduced inside the container on a real Linux filesystem:
fatal: detected dubious ownership in repository at '/srv/r'
To add an exception for this directory, call:
git config --global --add safe.directory /srv/r
Worth being precise about when you will actually hit this, because the advice on the internet is
over-general. On Docker Desktop for Mac we could not reproduce it through a bind mount:
virtiofs presents mounted files as owned by whatever UID the container runs as. Running the same
mount as --user 1000 showed 1000, as --user 1001 showed
1001, and git status was happy both times. It bites on Linux hosts, where
the UID is real and yours is probably not 1000. The fix either way is
git config --global --add safe.directory /workspace baked into the image, or matching
the UIDs with --user "$(id -u):$(id -g)". (The Linux-host bind-mount case is
marked untested here — we reproduced the error on a Linux filesystem inside the container,
not on a Linux Docker host.)
The network: allow one thing, deny the rest
This is where every container-the-agent project stalls. The agent needs the model API or it is a
very expensive bash. You want it to reach nothing else, because an agent with a whole
repository in its context and unrestricted egress is an exfiltration primitive waiting for a
prompt injection.
--network none is not the answer. We tried, and it fails in exactly the way you would
expect:
$ docker run --rm --network none agentbox:1 claude -p "hi"
Not logged in · Please run /login The working shape is default-deny egress with a small allowlist, applied inside the container at start-up. That needs two capabilities the container does not get by default:
docker run --cap-add=NET_ADMIN --cap-add=NET_RAW …
Anthropic's reference container does the same thing and says so plainly: "Running a firewall inside
a container requires extra permissions, so the reference adds the NET_ADMIN and
NET_RAW capabilities through runArgs." Here is the trimmed version we
actually ran, an ipset of resolved addresses in front of a DROP policy:
#!/bin/bash
set -euo pipefail
# Keep Docker's embedded DNS reachable, then start from nothing.
DOCKER_DNS_RULES=$(iptables-save -t nat | grep "127\.0\.0\.11" || true)
iptables -F; iptables -X
iptables -t nat -F; iptables -t nat -X
ipset destroy allowed-domains 2>/dev/null || true
if [ -n "$DOCKER_DNS_RULES" ]; then
iptables -t nat -N DOCKER_OUTPUT 2>/dev/null || true
iptables -t nat -N DOCKER_POSTROUTING 2>/dev/null || true
echo "$DOCKER_DNS_RULES" | xargs -L 1 iptables -t nat
fi
iptables -A OUTPUT -p udp --dport 53 -j ACCEPT
iptables -A INPUT -p udp --sport 53 -j ACCEPT
iptables -A INPUT -i lo -j ACCEPT
iptables -A OUTPUT -o lo -j ACCEPT
ipset create allowed-domains hash:net
for domain in \
api.anthropic.com \
claude.ai \
claude.com \
platform.claude.com \
registry.npmjs.org; do
ips=$(dig +short A "$domain" | grep -E '^[0-9.]+$' || true)
[ -z "$ips" ] && { echo "ERROR: failed to resolve $domain"; exit 1; }
while read -r ip; do ipset add allowed-domains "$ip" -exist; done <<< "$ips"
done
iptables -P INPUT DROP
iptables -P FORWARD DROP
iptables -P OUTPUT DROP
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A OUTPUT -m set --match-set allowed-domains dst -j ACCEPT
iptables -A OUTPUT -j REJECT --reject-with icmp-admin-prohibited
Note the ordering: DNS and loopback are allowed before the policy flips to DROP, and the
final rule is REJECT rather than a silent drop so a blocked tool fails in a second
instead of hanging until a timeout. The domain list comes from Anthropic's published network
requirements — api.anthropic.com for inference, claude.ai,
claude.com and platform.claude.com for sign-in and token refresh. Add
registry.npmjs.org only if the agent needs to install things.
Same container, before and after, measured:
| Host | Before | After |
|---|---|---|
example.com | HTTP 200 | blocked (curl exit 7) |
github.com | reachable | blocked (curl exit 7) |
api.anthropic.com | HTTP 405 | HTTP 405 — reachable |
registry.npmjs.org | HTTP 200 | HTTP 200 — reachable |
(405 is api.anthropic.com rejecting a bare GET on /v1/messages. Reaching
the 405 is the point.)
We also checked whether the agent could simply take the firewall down. It cannot, and it is worth seeing why: the capability belongs to root in the container, not to the unprivileged user the agent runs as, and the sudoers rule grants exactly one script.
# as the 'node' user, after the firewall is up
$ /usr/sbin/iptables -F
iptables v1.8.9 (nf_tables): Could not fetch rule set generation id: Permission denied (you must be root)
$ sudo -n /usr/sbin/iptables -F
sudo: a password is required
$ curl --connect-timeout 5 https://example.com
still blocked (curl exit 7) Where this allowlist leaks
Now the honest part, because this design has a hole that most write-ups skip. iptables filters IP addresses, not hostnames. Allow one host that sits on a shared CDN and you have allowed every host on the same anycast address. We demonstrated it with the npm entry, which resolves into Cloudflare space:
$ dig +short A registry.npmjs.org
104.16.6.34
# an unrelated host, pinned to that allowed IP
$ curl --resolve www.cloudflare.com:443:104.16.6.34 https://www.cloudflare.com
http=200 remote_ip=104.16.6.34
# the same host over normal DNS
$ curl https://www.cloudflare.com
BLOCKED (curl exit 7)
Blocked by name, reachable by address. Anthropic's own docs describe the same class of problem for
their proxy-based sandbox: because the allow decision comes from a client-supplied hostname without
inspecting TLS, "code running inside the sandbox can potentially use domain fronting or similar
techniques to reach hosts outside the allowlist," and "allowing broad domains such as
github.com can create paths for data exfiltration."
Two practical consequences:
- Keep the allowlist short. Every CDN-hosted entry you add widens it by more than
the one host you meant.
api.anthropic.comcurrently resolves to Anthropic's own address space (160.79.104.10, TTL 125 when we checked), which is a better neighbour than a general-purpose CDN. - The set is resolved once, at start-up. A long-running container will lose access when a record rotates behind it. Restart the container, or re-run the script, rather than debugging a mysterious API timeout at hour six.
If your threat model needs more than this, the next step is a real egress proxy that terminates TLS and filters by hostname, with its CA installed in the container. That is a bigger project, and it is the point at which the container stops being a weekend setup.
Do not mount the Docker socket
It comes up constantly, because agents want to run docker build and the obvious fix is
-v /var/run/docker.sock:/var/run/docker.sock. That flag hands the container the host
daemon. Docker's docs put it as directly as anyone could ask: "you can start a container where the
/host directory is the / directory on your host; and the container can
alter your host filesystem without any restriction."
We confirmed it end to end. A file on the host, in a directory the agent container was never given:
# inside the agent container — the path is not mounted
$ cat /…/host-only.txt
cat: /…/host-only.txt: No such file or directory
# but the socket is mounted, so it starts a sibling that mounts that path
$ curl --unix-socket /var/run/docker.sock -X POST -d '{"Image":"alpine",
"Cmd":["cat","/host/host-only.txt"],
"HostConfig":{"Binds":["/…/container-post:/host"]}}' \
http://localhost/containers/create?name=sockprobe
create http=201
start http=204
# what the sibling read back:
secret-the-container-was-never-given
Nothing was exploited here — that is the documented behaviour of the API. The point is that
the socket is not a Docker feature you are adding to the container; it is the container boundary
you are removing. One mitigation we did observe: as the unprivileged node user the
socket was unreadable and the same calls failed outright (curl exit 7), because the socket is
root-owned inside the container. That is a thin defence, not a design. If the agent needs to build
images, give it a rootless daemon of its own or a build service, not your socket.
How this compares to what the tools already give you
Both major CLIs now ship OS-enforced isolation, and the honest question is whether the container adds anything on top.
| Approach | What is inside the boundary | Blocks host damage | Blocks damage to your repo | Setup |
|---|---|---|---|---|
Claude Code Bash sandbox (/sandbox) | Bash commands and their children | Yes, for Bash | No — cwd is writable by design | Nothing on macOS; two packages on Linux |
Codex --sandbox | Every command Codex runs | Yes | Only in read-only | None |
@anthropic-ai/sandbox-runtime | The whole CLI process: tools, hooks, MCP | Yes | No | Low; beta research preview |
| Container (this article) | Everything in the session | Yes, short of an escape | No — bind mount | Medium; the firewall is most of it |
| VM | A whole OS, own kernel | Yes | Depends on how code gets in | High |
Read the fourth column twice. Not one of these protects your working tree, because every one of them is designed to let the agent edit it. If your actual fear is "the agent mangles my branch," the answer is git, not isolation — a branch, a worktree, or a commit before you walk away.
Claude Code's Bash sandbox is genuinely good and costs nothing to switch on: run
/sandbox, pick auto-allow, and sandboxed commands stop prompting because the OS
boundary already contains them. Its limit is scope, stated in the docs — it covers Bash, while
Read, Edit and WebFetch go through permission rules instead, and MCP servers and hooks "run
unconstrained on the host." sandbox-runtime closes that gap without Docker, at the cost
of being a beta with a configuration format that may still change.
Codex takes the same idea further into the CLI itself, with three modes and a separate approval
dial; we covered the mechanics in
Codex CLI sandbox modes. One relevant crossover: its
Linux backend uses bubblewrap and unprivileged user namespaces, so a hardened container that forbids
those will make Codex warn or refuse. Running Claude Code's Bash sandbox inside a container has the
same shape — the docs describe an enableWeakerNestedSandbox setting for
unprivileged containers where bubblewrap cannot mount a fresh /proc, and warn that it
"considerably weakens security." (Nested sandboxing is untested here; we ran the container
without the inner sandbox.)
The one thing a container adds that nothing else on the list does: it is the environment in which
--dangerously-skip-permissions is a defensible choice rather than a shrug. Anthropic's
own framing is that "with no prompts to catch mistakes, the isolation boundary you choose is what
protects your system," and that a firewall-equipped container is what makes the flag supportable.
Notice, though, that Claude Code now offers a middle option that did not exist a year ago: auto mode
runs a classifier over each action instead of prompting you, and the docs are explicit that it is "a
per-action control, not an isolation boundary" — so a container is defence in depth there
rather than a prerequisite.
The verdict: is it worth the friction?
Our honest read, having built the thing.
Skip the container if you are working on your own repositories on your own machine and the thing you want is fewer prompts. The built-in sandbox with auto-allow, plus a handful of pre-approval rules for the commands you run constantly, gets you most of the quiet for about ten minutes of setup and none of the maintenance. A container will not stop the failure mode you actually experience, which is the agent making a mess of your branch.
Build the container when at least one of these is true:
- You want to start an agent and leave — overnight, or across a long task — with permission prompts fully off. This is the strongest case, and the only one where the container is doing load-bearing work.
- You are running third-party skills, plugins or MCP servers you have not read. These execute outside the built-in Bash sandbox and inside a container, which is a real difference rather than a theoretical one.
- You want a reproducible toolchain for a team, and the isolation is a bonus on top of the actual goal.
- Your host is Windows, where the built-in Bash sandbox does not run natively at all.
Skip straight to a VM if the repository itself might be hostile. A container shares your kernel; that is the case where the difference matters, and it is the case Anthropic's own guidance routes to a VM.
And whichever you choose, the two rules that survive all of it: branch before you walk away, and keep the egress allowlist short. Those cover more real incidents than the boundary does.
Where Backgrind fits
We will be straight about this: Backgrind does not run your agent in a container, and putting one there does not need us. What it does need is a way to see what came back, because the reason people reach for containers is to stop watching — and an agent you have stopped watching is one whose "done" you will notice forty minutes late.
Backgrind runs your real CLI in an always-on-top overlay and turns turn-completion and held permission prompts into ambient toasts you can answer with one keystroke, over whatever is already on screen. That is complementary to the isolation, not a substitute for it: the container decides what a mistake can reach, the overlay decides how long it takes you to find out. If you keep one prompt gated on purpose — a deploy, a force push — that gate only works if you see it. Watch it in the live demo.
Frequently asked questions
Is a container a security boundary for a coding agent?
Not against a determined escape, and not for what you mounted into it. Docker's own docs say the default capabilities and mounts "may provide incomplete isolation," and a container shares your kernel. Treat it as a strong boundary against accident and a weak one against intent. For genuinely untrusted code, use a VM.
Can the agent still wreck my repo in a container?
Yes. A read-write bind mount is your working tree under a different path — we wrote a file inside the container and it appeared on the host immediately. Commit or branch first; that is the mitigation, not the container.
How do credentials get in safely?
Never at build time: a --build-arg token appears in docker history three times even after you delete the file that held it. Use a run-time env var from claude setup-token, or sign in once into a named volume at ~/.claude with CLAUDE_CONFIG_DIR pointed at it. On macOS there is no credentials file to mount — the token lives in the login Keychain.
Why is my mounted ~/.claude volume read-only for the agent?
A fresh named volume is seeded from the image path, ownership included. If the directory does not exist in the image, Docker creates it root-owned and your non-root agent user gets Permission denied. Add RUN mkdir -p /home/node/.claude after the USER line.
How do I allow the model API and block everything else?
Default-deny iptables plus an ipset of resolved IPs at container start, with --cap-add=NET_ADMIN --cap-add=NET_RAW. It works — but it filters addresses, not names, so an allowed CDN IP lets through every host sharing it. Keep the list short.
Why does --dangerously-skip-permissions refuse to start?
Your container is running as root. The flag is blocked under root or sudo with "cannot be used with root/sudo privileges for security reasons". Add a USER line for a non-root account.
Is the built-in sandbox enough on its own?
For everyday work, yes. It covers Bash commands and their children at the OS level. It does not cover MCP servers or hooks, which run unconstrained on the host — that gap, and unattended runs with prompts off, are what the container is for.