# From Docker Compose to Docker Sandboxes: running Claude Code in sbx

## Where I was: Claude Code in Docker Compose

For the past couple of months I have been running Claude Code inside a Docker container, started with Docker Compose on Docker Desktop. My Dockerfile is based on the .NET 10 SDK image, adds Node.js and the Claude Code CLI, and runs everything as a non-root user so `--dangerously-skip-permissions` works. The project folder is bind mounted to `/workspace`, and a named volume keeps the Claude login alive between restarts.

It works, and I still like it. But getting there cost me some debugging: a UID/GID 1000 collision in the base image, Windows paths that silently failed to mount because of backslashes, and later two Compose projects in the same folder that ended up sharing one named volume. That last one quietly logged my personal container into my work account.

The bigger point is that a container shares the host kernel. When I let an agent run with all permission prompts turned off, I want a stronger wall than that. So I tried Docker Sandboxes, the `sbx` CLI, next to my Compose setup.

## What sbx actually is

Docker Sandboxes runs each coding agent in a lightweight microVM with its own Linux kernel, not just a container namespace. Inside that VM the agent gets a private Docker daemon, so it can run `docker build` or `docker compose up` without touching the Docker on my host.

The parts that matter most to me:

- **Network levels.** The first time you run a sandbox you pick one of three levels: Open, Balanced or Locked down. Outside of Open, outbound traffic is blocked unless a rule allows the destination. `sbx policy ls` shows the active rules and `sbx policy log` shows what the sandbox tried to reach.
- **Credentials stay on the host.** Secrets set with `sbx secret` are injected into outbound HTTP headers by a host-side proxy. The agent can use them but cannot read the raw values.
- **Hard limits on the host.** The host filesystem outside the mounted workspace, the host Docker daemon and direct traffic between sandboxes are always blocked, whatever the policy says.
- **Full control inside.** The agent has sudo, can install packages with apt, npm or dotnet, and everything persists across stop and start until `sbx rm`.

The sbx docs say you don't need Docker Desktop or Docker Engine on the host, but building your own kit needs Docker with Buildx. On my machine that is Docker Desktop. The CLI and local sandboxes are free, also for commercial work; you only pay your model provider.

## Getting started on Windows

On Windows 11 you need Windows Hypervisor Platform for local sandboxes. Turn it on once from an elevated PowerShell prompt, then install the CLI per user with winget (no admin needed):

```powershell
Enable-WindowsOptionalFeature -Online -FeatureName HypervisorPlatform -All
winget install -h Docker.sbx
sbx login
```

`sbx login` opens a browser for your Docker account. After that, starting Claude against a project is one line:

```powershell
cd C:\Workspace\Repos\Pat.Aca.HangmanApi
sbx run claude .
```

Inside the sandbox I log in with `/login` to use my Claude subscription. If you use an API key instead, store it with `sbx secret set anthropic` and the proxy injects it.

Compare that with my Compose setup: a Dockerfile, a compose file, build args for the git identity, a volume and a mount path I had to get exactly right. With the built-in agent there is no Dockerfile at all. The default image is `docker/sandbox-templates:claude-code`, and it starts `claude --dangerously-skip-permissions` for you. I still ended up writing a Dockerfile, because I want my own tools in the image. That is the next section.

## Bringing my own tools: a kit

The built-in agent does not have the .NET 10 SDK, the GitHub CLI, Playwright with Chromium or ffmpeg. In Compose all of that lived in my Dockerfile, so I wanted it back. In sbx you do that with a kit: a folder with two files that must have the same name before the extension.

```text
claude-dotnet/
├── claude-dotnet.dockerfile
└── claude-dotnet.yaml
```

The Dockerfile starts from Docker's own Claude image, which already has Claude Code, git, Node.js and the non-root `agent` user. I only add my tools. System packages go in as root. Anything that lives in the home directory, like the .NET SDK, goes in as `agent`, otherwise it ends up under `/root` where the agent cannot use it.

```dockerfile
FROM docker/sandbox-templates:claude-code

USER root

# System packages: ffmpeg, plus ICU for .NET
RUN apt-get update && apt-get install -y --no-install-recommends \
      ffmpeg libicu-dev \
    && rm -rf /var/lib/apt/lists/*

# GitHub CLI (official apt repo)
RUN mkdir -p -m 755 /etc/apt/keyrings \
 && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
      -o /etc/apt/keyrings/githubcli-archive-keyring.gpg \
 && chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
 && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
      > /etc/apt/sources.list.d/github-cli.list \
 && apt-get update && apt-get install -y gh \
 && rm -rf /var/lib/apt/lists/*

# Playwright + Chromium in a shared path so the agent user can use it
ARG PLAYWRIGHT_VERSION=1.61.0
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright
RUN npx --yes playwright@${PLAYWRIGHT_VERSION} install --with-deps chromium \
 && chmod -R a+rX /opt/ms-playwright

# .NET 10 SDK as the agent user
USER agent
RUN curl -fsSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh \
 && bash /tmp/dotnet-install.sh --channel 10.0 --install-dir /home/agent/.dotnet \
 && rm /tmp/dotnet-install.sh
ENV DOTNET_ROOT=/home/agent/.dotnet \
    PATH=$PATH:/home/agent/.dotnet:/home/agent/.dotnet/tools \
    DOTNET_CLI_TELEMETRY_OPTOUT=1

ENTRYPOINT ["claude"]
CMD ["--dangerously-skip-permissions"]
```

The `ENTRYPOINT` and `CMD` at the end repeat what the built-in agent does. If you use Playwright for .NET, the NuGet package version should match `PLAYWRIGHT_VERSION`, so it finds the browser in the image instead of trying to download its own.

The YAML file tells sbx that this is a workload and which hosts the running sandbox may reach. Downloads that happen while the image is built use the builder's network, so they don't need a rule here.

```yaml
# syntax=docker/sandbox-kit:3
schemaVersion: "3"
kind: workload
capabilities:
  - type: com.docker.sandbox/sbx@1
  - type: com.docker.sandbox/network-policy@1
    config:
      runtime:
        allow:
          - api.anthropic.com
          - claude.ai
          - api.nuget.org
          - github.com
          - api.github.com
          - cdn.playwright.dev
```

Then I pass the kit folder instead of an agent name, followed by the project folder:

```bat
sbx run C:/work/docker-sbx/claude-dotnet C:/Workspace/Claude --name work
```

The first run builds the image, which takes a while with Chromium in it. Later runs reuse the cache. Inside the sandbox the project shows up as `/c/Workspace/Claude`, the Windows path mirrored. A sandbox keeps the kit it was created with, so after changing the Dockerfile you need a new sandbox under a new name.

### Three sandboxes, one kit

I run three projects this way: work, blog and website. Each has its own `.bat` file with a different folder and a different sandbox name, and all three point to the same kit.

```bat
@echo off
cd /d "%~dp0"
sbx run C:/work/docker-sbx/claude-dotnet C:/Workspace/Claude --name work
echo.
echo sbx exited with code %ERRORLEVEL%
pause
```

The `cd /d "%~dp0"` line makes the script start in its own folder. The `pause` keeps the window open, so I can read an error instead of watching the window disappear.

## Compose vs sbx side by side

The biggest difference is where the wall sits: a namespace on a shared kernel versus a VM with its own kernel and a locked-down network.

|  | Docker Compose (my setup) | sbx |
| --- | --- | --- |
| Isolation | Container, shared host kernel | microVM, own kernel |
| Setup | Dockerfile + compose file + volume | `sbx run claude .` |
| Network | Open by default | Three levels: Open, Balanced, Locked down |
| Credentials | Whatever I mount or pass as env | Injected by host proxy, agent can't read them |
| Docker inside | Needs the host socket (I don't mount it) | Private daemon per sandbox |
| Claude login | Named volume | Kept in the sandbox until `sbx rm` |
| Two accounts | Separate folders so volumes don't clash | Separate named sandboxes |
| Host needs | Docker Desktop | Only the sbx CLI + hypervisor |
| Toolchain | Exactly what I put in the Dockerfile | Same Dockerfile content, moved into a kit (Dockerfile plus a small YAML file) |

## Git: direct mount, and clone mode as the stricter option

My `.bat` files mount the project folder directly, which is sbx's default direct mode. The agent edits my working tree in place, so the changes are there to review on the host before anything is pushed.

In my Compose setup the agent committed locally and I pushed from the host after reviewing. I enforced that by giving the container no remote credentials, so `git push` was impossible rather than just discouraged.

Direct mode has one consequence worth knowing. The agent can read, write and delete anything in the mounted folder, including hidden files, build scripts and `.git` hooks. A changed hook would run on my machine the next time I use git in that folder.

sbx has a stricter option for that: clone mode. With `--clone`, the agent works in its own git clone inside the sandbox. My host repo is only mounted read-only, and nothing changes on my machine until I fetch it.

```powershell
sbx run --clone claude .
```

The agent creates a branch, and sbx adds a `sandbox-<name>` remote to my repo so I can review from the host:

```powershell
git fetch sandbox-<name>
git diff main..sandbox-<name>/feat/my-feature
git checkout -b feat/my-feature sandbox-<name>/feat/my-feature
git push -u origin feat/my-feature
```

Clone mode is fixed when the sandbox is created, so switching later means creating a new sandbox. It also pairs well with Claude Code's agents view, which runs several background sessions in parallel, each on its own branch inside the same sandbox.

## What went wrong, in order

These are the errors I hit, in the order I hit them. None of them were hard to fix, but most were not obvious from the message.

### The kit path did not exist

`error: resolve kits: kit path ./claude-dotnet does not exist`

sbx looks for a relative kit path from the folder where you run the command. Once the kit folder was where sbx looked, this went away. An absolute path in the `.bat` file removes the question.

### The workload had no content

`workload kit claude-dotnet.yaml has no content ... declare an inline build: block or a companion claude-dotnet.dockerfile`

sbx read my YAML file but did not find a Dockerfile to go with it. The Docker docs say the YAML file and the Dockerfile must have the same name before the extension, so the build can find both. Giving them matching names fixed it. If you create the files in Notepad, check the exact names with `dir /b`, because Windows can hide an extra `.txt` extension.

### Playwright did not support the base image

`Playwright does not support chromium on ubuntu26.04-x64`

The base image is Ubuntu 26.04. I had pinned Playwright 1.50.0, which does not know that release. Support for Ubuntu 26.04 arrived in Playwright 1.61.0, so I set `PLAYWRIGHT_VERSION` to 1.61.0 and the image built.

### The laptop rebooted during setup

Windows restarted in the middle of setting up the sandbox with Playwright 1.61.0. The System log had event 41 and event 6008, an unexpected shutdown, and no blue screen entry (event 1001), so Windows did not write a crash report. I do not know the cause. You can look at the same entries yourself:

```powershell
Get-WinEvent -FilterHashtable @{LogName='System'; Id=41,1001,6008} -MaxEvents 5 | Format-List TimeCreated,Id,Message
```

### The image was ready but the sandbox would not start

`error: cannot create sandbox: failed to run sandbox container`

This came after `Image ready`, so the Dockerfile was fine. Starting the sandbox once from an elevated PowerShell window made it work, and after that a normal window works too. I do not know what needed the administrator rights, and I had already enabled Windows Hypervisor Platform. Docker's troubleshooting page suggests `sbx diagnose` and `sbx daemon restart` as first steps.

### I logged in with the wrong account

Claude Code's `/login` opened my browser, and it went straight through with my work account. That was my own mistake. The page showed which account was signed in at the bottom, with a Switch accounts link next to it, and I did not read it. Because every sandbox has its own login, I now look at that line each time I log in to a new one.

## Things to watch out for

- **Your `~/.claude` is not used.** Sandboxes ignore user-level config from the host. Only project-level configuration in the working directory comes along, so what matters goes in the repo: `CLAUDE.md` and `.claude/settings.json`.
- **Pick the network level on purpose.** The first time you run a sandbox, sbx asks you to choose one of three levels. Open allows all outbound TCP. Balanced is default deny with a baseline allowlist for AI provider APIs, package managers, code hosts, container registries and common cloud services. Locked down allows nothing until you add a rule. Under Balanced and Locked down, a request with no matching rule is blocked and sbx asks for your approval (`sbx policy approval ls`), so access opens up one destination at a time.
- **Open is the risky one.** With permission prompts off, anything the agent can read in the mounted folder can leave over the network. Prompt injection from a README, an issue or a package is the realistic route, and secrets left in `appsettings*.json` are the obvious thing to lose. Some things stay blocked at every level: host files outside the mounted folder, the host Docker daemon and traffic between sandboxes.
- **Blocked requests are visible.** `sbx policy log` shows what the sandbox tried to reach, and `sbx policy approval ls` shows the requests waiting for your approval. `sbx policy allow network <host>` opens a destination. `sbx policy reset` clears all rules, stops running sandboxes and asks for a level again.
- **Approvals happen on the host.** When my blog was blocked, I approved it from a separate PowerShell window on my own machine, not from inside the sandbox: `sbx policy approval respond <id> --option allow`. `sbx policy approval ls` shows the pending requests and their IDs. An approval only helps later requests, so I ran the action again afterwards. It is stored as a rule for that one sandbox.
- **Push is a policy question.** SSH agent forwarding is on by default when `SSH_AUTH_SOCK` is set, so sandboxed processes can ask your host agent to authenticate. If you want the agent unable to push, don't allow your git host, don't store a GitHub secret, and consider `sbx settings set ssh.agentForwardingEnabled false` followed by `sbx daemon restart`.
- **A Docker account is required.** `sbx login` is needed even for local sandboxes.

## Where I ended up

I moved all three of my Claude Code projects, work, blog and website, from Compose to sbx. Each one runs in its own sandbox with its own folder and its own login, and all three share one kit built from my own Dockerfile.

Setup was easier than with Compose: no compose file, no volumes, and the Dockerfile content carried over into the kit. The time I lost was all on the way in. The kit file names, the Playwright version, a laptop reboot, a first start that needed administrator rights, and logging in with the wrong account. Once past those, the three sandboxes start from three `.bat` files.

If you already run Claude Code in Docker, sbx is worth an evening. The VM boundary and the network levels are things I would otherwise have to build by hand.

## Sources

- [Docker Sandboxes overview](https://docs.docker.com/ai/sandboxes/)
- [Install Docker Sandboxes](https://docs.docker.com/ai/sandboxes/install/)
- [Claude Code in Docker Sandboxes](https://docs.docker.com/ai/sandboxes/agents/claude-code/)
- [Default security posture](https://docs.docker.com/ai/sandboxes/security/defaults/)
- [Use Git with sandboxes](https://docs.docker.com/ai/sandboxes/workflows/git/)
- [Base images for sandbox workloads](https://docs.docker.com/ai/sandboxes/customize/author/base-images/)
- [Build an agent workload](https://docs.docker.com/ai/sandboxes/customize/author/build-an-agent/)
- [Local policy (network levels)](https://docs.docker.com/ai/sandboxes/governance/access-controls/local/)
- [Troubleshooting](https://docs.docker.com/ai/sandboxes/troubleshooting/)
- [Playwright: support Ubuntu 26.04](https://github.com/microsoft/playwright/pull/41025)

---

*Co-authored with Claude.*

If this was useful, [you can buy me a coffee](https://ko-fi.com/p47k0).

New articles: [follow via RSS](https://blog.koorevaar.com/feed.xml).
