AI Agents · Docker
One Container
per AI Agent
When several coding agents share one machine, they are usually kept apart by convention: a lock per job, a port per script, a cleanup command that hopes it kills the right process. Giving each agent its own container makes the unit you isolate and the unit you reclaim the same thing. These are design notes for that setup, plus a lighter cgroup-only version for when containers cost more than they save.
On my workstation, a single WSL machine with 8 cores and about 10 GB of memory, several AI coding agents run at the same time. Some are subagents I start from an interactive session. Others are scheduled jobs that start a headless session every hour. They all run as the same user, in the same filesystem, against the same Docker daemon.
What keeps them apart is mostly convention. Each scheduled job holds a file lock so it never overlaps with itself, but nothing stops two different jobs from running browser tests at once. A dev server on a fixed port collides as soon as two agents start one, and the obvious cleanup (kill whatever holds that port) would kill the other agent's server. I have had a lock held for days by a process that had already died, five parallel test suites that each started their own database containers wedge the Docker daemon, and an agent that was supposed to work in its own git worktree write into the main checkout anyway.
These notes describe the setup I would move to: one container per agent. I haven't applied it yet. On a machine this small the setup cost looks larger than what it would save for now. Some of the pieces below I tested on the machine, and I say so where that's the case.
Isolation and Reclamation as One Unit
The goal here isn't a security sandbox. The goal is that everything an agent starts (its shell, dev servers, browsers, test databases) lives inside one boundary with a resource ceiling, and that one command removes all of it. A container gives both because every container runs in its own cgroup. docker rm -f kills every process in it, including ones that daemonized themselves or started a new session.
docker run --rm --init \
--name agent-a1 \
--label agent.id=a1 --label agent.budget=3600 \
--cpuset-cpus 2,3 \
--memory 2500m --memory-swap 2500m \
--pids-limit 512 --shm-size 1g \
--network agents \
-v "$HOME/work/a1":/work -w /work \
-v "$HOME/.agent-secrets/a1":/run/secrets:ro \
agent-browser:2026.10 \
sh -c 'CLAUDE_CODE_OAUTH_TOKEN=$(cat /run/secrets/oauth-token) exec claude -p "$1"' sh "$TASK"--cpus(not used above) caps total CPU time, and--cpuset-cpuspins the agent to specific cores. For checks that measure time, like frame time in a browser, pinning is better. With a quota, agents still share the same cores, and an agent that uses up its share is paused for the rest of the scheduling period, so timings jitter. Pinned cores keep agents from disturbing each other.--memorywith the same value for--memory-swapturns swap off, so a leaking agent dies at its own ceiling instead of slowing down the whole machine.--pids-limitstops a loop that keeps spawning processes.--shm-sizematters for browsers. Docker gives/dev/shm64 MB by default, and Chromium can run out of it and crash.--ipc=hostalso works but gives up part of the isolation.--initruns a tiny init process as PID 1 that reaps zombies. Without it, an orphaned child that exits stays defunct until the container is gone.- Labels let a sweeper find what belongs to which agent and how long it was allowed to run.
On 10 GB the arithmetic is unforgiving. An agent with a headless browser, a dev server and the CLI itself needs 2 to 2.5 GB, so three slots is about the limit. Containers don't remove the need to cap how many agents run at once, but each slot gets a ceiling it can't exceed.
Agents Talk Over the Network
Each container gets its own network namespace, and that alone fixes the port problem. Every agent can run its dev server on 5173, because each 5173 lives in a different namespace. Scripts that hardcode a port stop being a hazard.
Agents that need to coordinate do it over a user-defined bridge network, where Docker's embedded DNS resolves container names. The pattern I would use is a small coordinator with a mailbox per agent. An agent posts a message to another agent's mailbox and long-polls its own. Code travels through git, as branches pushed to a shared remote. There is no shared writable directory, because a shared mount would let agents overwrite each other's files again. (mailbox and egress-proxy below stand for small services I would write, not published images.)
docker network create --internal agents
docker network create egress
docker run -d --name proxy --network egress egress-proxy
docker network connect agents proxy
docker run -d --name mailbox --network agents mailbox
# inside agent a1
curl -s -X POST http://mailbox:8080/agents/a2 \
-d '{"from":"a1","body":"schema is ready on branch a1/schema"}'
curl -s "http://mailbox:8080/agents/a1?wait=60"The agents' network is created with --internal, so nothing on it can reach the internet directly. Agents still need the model API, the git host and package registries, so one egress proxy joins both the internal network and a normal one and forwards only to an allowlist of hosts. Agents get HTTPS_PROXY=http://proxy:3128. This also limits what a prompt-injected agent can do, because it can't send data to an arbitrary host.
Containers Inside Containers, Only When Needed
Most agents never need Docker. The ones that do, for integration tests with Testcontainers or for building images, need a decision, because every option trades isolation against cost.
| Option | Isolation | Reclamation |
|---|---|---|
| Mount the host's Docker socket | None. Access to the socket is root on the host | Child containers are siblings, outside the agent's limits |
Docker-in-Docker (--privileged, own daemon) | Own daemon and storage, but the container is privileged | One docker rm -f -v removes everything, image cache included. The official image keeps /var/lib/docker in an anonymous volume, which plain rm -f leaves behind |
Sysbox runtime (--runtime=sysbox-runc) | A nested daemon without --privileged | Same as Docker-in-Docker. The runtime must be installed on the host |
| Rootless Podman inside the container | Good, and there is no daemon, but it needs /dev/fuse and a looser seccomp profile, and Testcontainers needs podman system service running | Child processes stay inside the agent's cgroup |
The socket option is the cheapest, and --cgroup-parent brings resource ceilings back to it. With the systemd cgroup driver the flag takes a slice name. I checked that --cgroup-parent=agent-a1.slice puts the container under /agent.slice/agent-a1.slice. If the agent's own container and every container it starts use the same slice, a limit set on the slice covers all of them, and stopping the slice reclaims them together. Setting slice properties needs root.
docker run --rm --init \
--cgroup-parent agent-a1.slice \
-v /var/run/docker.sock:/var/run/docker.sock \
--group-add "$(stat -c %g /var/run/docker.sock)" \
--network agents \
-e TESTCONTAINERS_RYUK_DISABLED=true \
...
sudo systemctl set-property agent-a1.slice MemoryMax=4G CPUQuota=300%
sudo systemctl stop agent-a1.sliceThe weak spot is enforcement. The socket accepts any API call, so an agent can start a container without the flag. Forcing it would take a proxy in front of the socket that rewrites container-create requests. The socket proxies I know of filter which endpoints are allowed but don't add fields to a request.
Testcontainers inside an agent container also needs a way to reach the containers it starts. By default it looks for their ports on the host, through the host gateway, and the --internal network above has no route there. On my machine it failed at its first step, connecting to its own cleanup container (Ryuk). What worked was keeping everything on the agents network: Ryuk turned off with TESTCONTAINERS_RYUK_DISABLED=true, the database started on agents with a network alias, and a wait on its log line instead of the default port check. The agent then reached the database by alias and internal port.
The cost lands in the tests and the sweeper. Tests have to connect by alias and internal port, not by the host and mapped port Testcontainers reports. The alias has to include the agent's id (a1-db, not db), because every agent shares this network and two containers with the same alias both answer to it. And with Ryuk off, the sweeper has to remove whatever a killed agent's tests leave behind.
My default would be no Docker at all, the socket plus a slice for agents that start test databases, and Sysbox for agents that build images or need their own daemon. Sysbox's support table doesn't list WSL2, and I haven't tried it there. Five agents starting database containers on one shared daemon is how I wedged it. A separate daemon per agent moves that failure inside the agent that caused it.
Sharing Credentials Without Baking Them In
Agents need up to three kinds of credentials: the model API, the git host, and sometimes a cloud account. None of them belong in an image layer.
- The model credential. The CLI's interactive login stores an OAuth token that it refreshes and writes back to a file. Mount that file into several containers and several processes race to refresh one token. A long-lived token from
claude setup-token, or an API key, given to each container avoids the shared write. - Files, not environment variables. Environment variables show up in
docker inspectand in every child process. A read-only file under/run/secretskeeps the secret out ofdocker inspect. The CLI takes asetup-tokentoken only fromCLAUDE_CODE_OAUTH_TOKEN, though, so the command above reads the file and sets the variable for the CLI alone, and the agent's own child processes still inherit it. An API key can stay out of the environment entirely throughapiKeyHelper, a script the CLI runs to get the key. - Short-lived git tokens. Instead of one personal token that can reach every repository, a coordinator that holds a GitHub App key mints an installation token per agent, scoped to the repositories that agent works on. These tokens expire after one hour, so a killed agent's token stops working without anyone revoking it.
- SSH. Mount the SSH agent's socket (
SSH_AUTH_SOCK) instead of the keys. - Settings. Shared settings and agent definitions go in read-only. Each container gets its own writable home directory, so session state and caches don't collide.
How the Image Is Built
One image with everything in it gets large and slow to rebuild. I would split it by how often each part changes, as build targets of one Dockerfile.
FROM node:22-bookworm-slim AS node
RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g @anthropic-ai/claude-code@2.1.289
USER node
WORKDIR /work
FROM mcr.microsoft.com/playwright:v1.62.0-jammy AS browser
RUN npm install -g @anthropic-ai/claude-code@2.1.289
USER pwuser
WORKDIR /work- Toolchains are separate targets: Node, JVM and Python agents don't need to carry each other's runtimes.
- The browser target starts from Playwright's official image, pinned to the Playwright version the project uses. If the versions differ, the browsers in the image don't match what the test library expects. That image is about 2.3 GB on my machine, the largest piece by far.
- The CLI version is pinned in the image. An upgrade becomes a rebuild, never something that changes under a running agent.
- Caches live in volumes. npm's content-addressed cache is built to tolerate concurrent use, so one named volume can be shared.
node_modulesstays per worktree, in a volume tied to that agent. - The image runs as a non-root user, and no secret is in any layer.
What Reclamation Looks Like, and What It Doesn't Fix
The lifecycle is short. A wrapper starts the container with --rm and labels, and when the task ends it removes the container and the agent's worktree. A sweeper on a timer removes anything whose label says it has outlived its time budget. A timeout that removes a container takes the whole process tree with it, which a timeout around one process can't promise.
Three things stay unsolved. A process stuck in uninterruptible kernel I/O (D state), for example after disk errors, can't be killed from user space, and removing its container hangs too. The Docker daemon is one shared point of failure. When it wedges, running containers keep going, but nothing can be started or removed. And memory is still the budget, so a cap on concurrent agents is still needed.
The Lighter Alternative: A cgroup per Agent
Most of the reclamation benefit comes from the cgroup, not the container. systemd can create one per agent without images, mounts or credential plumbing.
systemd-run --user --unit=agent-a1 \
--same-dir --setenv=PATH="$PATH" \
-p MemoryMax=2500M -p MemorySwapMax=0 \
-p TasksMax=512 -p CPUQuota=200% \
-p RuntimeMaxSec=3600 \
sh -c 'CLAUDE_CODE_OAUTH_TOKEN=$(cat "$HOME/.agent-secrets/a1/oauth-token") exec claude -p "$1"' sh "$TASK"
systemctl --user stop agent-a1I tested this on the machine. A child that escaped with setsid was still inside the unit, and systemctl --user stop removed it. MemoryMax and TasksMax were applied. CPUQuota was not. The user session there (systemd 249) delegates only the memory and pids controllers, so the CPU limit was dropped without any error. Enabling it takes a drop-in for user@.service and a restart of the user manager. A new login isn't enough while another session keeps it running, and on WSL that means restarting the distribution.
sudo mkdir -p /etc/systemd/system/user@.service.d
sudo tee /etc/systemd/system/user@.service.d/delegate.conf <<'EOF'
[Service]
Delegate=cpu cpuset io memory pids
EOF
sudo systemctl daemon-reloadA transient unit doesn't inherit the shell's environment or working directory, which is what --same-dir and --setenv are for. Giving --setenv only a name, to copy the value from the calling shell, needs systemd 250. On 249 it fails with "Invalid environment block", so the value is passed explicitly. Anything passed with --setenv also shows up in systemctl --user show, the same exposure as docker inspect, so the token is read from a file inside the unit. Plain systemd-run returns as soon as the unit starts. Waiting for it with --wait needs a user D-Bus session, which on Ubuntu comes from the dbus-user-session package.
And a cgroup isolates neither the network nor the filesystem, so it needs three conventions alongside it.
- Dynamic ports. Pick a port per run and start the server with a strict-port option, so it fails instead of drifting to the next port. Clean up by unit or process group, never by port.
- A git worktree per agent. Then check the main checkout afterwards, because an agent can still write outside its worktree.
- Slots for scarce resources. N lock files act as a counting semaphore. An agent that needs a browser takes a free slot, or exits with 75 so the scheduler tries again later.
mkdir -p ~/.cache/agent-slots
for slot in 1 2 3; do
exec 8>~/.cache/agent-slots/browser.$slot
flock -n 8 && break
exec 8>&-
done
[ -e /proc/self/fd/8 ] || { echo "no browser slot free"; exit 75; }
claude -p "$TASK" 8>&-The lock belongs to the open file, not to a process, so any child that inherits fd 8 keeps the slot taken, including a dev server that outlives the agent. Starting the agent with 8>&- leaves the lock with the wrapper alone. The wrapper has to stay alive until the agent ends, which with the cgroup version means systemd-run --wait.
| Container per agent | cgroup per agent | |
|---|---|---|
| Resource ceiling | CPU, memory, processes, shared memory | Memory and processes (CPU after delegation) |
| Reclamation | docker rm -f removes every process | systemctl --user stop removes every process |
| Port collisions | Gone (own network namespace) | Need dynamic ports |
| Filesystem | Only what is mounted | Everything the user can reach |
| Agent-to-agent channel | Network with DNS names | Whatever the agents agree on |
| Setup cost | Images, mounts, credentials, networks | One command around the existing one |
My order would be the cgroup first. It wraps the command I already run, and it fixes the failure I see most often, which is something left running after its agent is gone. Containers come in once port and filesystem collisions start to cost more than the setup, or once more agents run at the same time than a handful of locks can keep apart.
Docker: resource constraints (the memory and CPU flags) · systemd.resource-control (MemoryMax, CPUQuota, TasksMax and delegation) · Sysbox (nested Docker without a privileged container) · Developer Experience in Containerized Environments (the same container contract, from the human developer's side) · When the Tool Output Itself Tries to Manipulate the Agent (what instructions hidden in tool output look like, and why an agent's network reach matters)