← All posts

Guide

Running a coding agent inside a container

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.

your machine ~/.ssh, ~/.aws out of reach your other repos out of reach the repo you mounted fully writable container the agent CLI its hooks its MCP servers its subprocesses non-root user bind mount api.anthropic.com allowed everything else REJECT Two things cross the boundary on purpose. Those two are your remaining risk.
The container is only as good as its two deliberate holes: the directory you mounted so the agent can work, and the network you opened so it can reach a model.

What you genuinely get:

What you do not get, in descending order of how often it bites:

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.

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:

HostBeforeAfter
example.comHTTP 200blocked (curl exit 7)
github.comreachableblocked (curl exit 7)
api.anthropic.comHTTP 405HTTP 405 — reachable
registry.npmjs.orgHTTP 200HTTP 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:

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.

ApproachWhat is inside the boundaryBlocks host damageBlocks damage to your repoSetup
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:

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.