A reusable, project-agnostic template that runs Claude Code inside a Docker dev container instead of on your host machine. Files still edit on your host (bind-mounted in), but every command Claude runs — shell commands, package installs, git operations — executes inside the container, and outbound network access is restricted to an allowlist.
Built on plain ubuntu:24.04 with no language runtime, so it's a starting point for any project.
generic(this one, the default) — the template to start projects from. Ubuntu base, no generalsudo, opt-in package registries, Claude Code from Anthropic's signed apt repo.anthropic— a near-verbatim mirror of Anthropic's reference dev container, kept as a clean baseline to diff this one against. Useful for seeing what upstream does differently; not meant for project use.
By default Claude Code (like any terminal tool) can touch anything your user account can touch: your whole filesystem, SSH keys, cloud credentials, browser data. Running it in a dev container instead means:
- Filesystem isolation — Claude can only reach
/workspace(this repo) and whatever else you explicitly mount. Your home directory, SSH keys, and other projects are not visible unless you mount them. - Network isolation —
init-firewall.shapplies a default-deny outbound policy and only allows the destinations listed in the script. Everything else is rejected. - The container can't rewrite its own sandbox —
.devcontainer/is mounted read-only, so nothing running inside can weaken the firewall or the Dockerfile for the next rebuild. See Protecting the container config. - Safe to use
--dangerously-skip-permissions— because the blast radius of an unattended session is contained to the container, it's reasonable to skip Claude's per-action confirmation prompts for long unattended runs.
This is not bulletproof. A malicious dependency or prompt-injected instruction can still
exfiltrate anything reachable inside the container — including your ~/.claude credentials —
over an allowlisted domain, and it can rewrite any file in your bind-mounted workspace apart from
.devcontainer/. Only point this at repositories you trust, and don't mount host secrets like
~/.ssh or cloud credential files into it. See Anthropic's docs on this trade-off:
https://code.claude.com/docs/en/devcontainer
.devcontainer/
devcontainer.json # container settings, mounts, VS Code config
Dockerfile # ubuntu:24.04 + firewall tooling + Claude Code
init-firewall.sh # default-deny egress allowlist
managed-settings.json.example # optional org policy (see below)
verify.sh # asserts the isolation actually holds
- Install Docker and, in VS Code, the Dev Containers extension.
- Open this folder in VS Code. When prompted, click Reopen in Container
(or
Cmd/Ctrl+Shift+P→ Dev Containers: Reopen in Container). - Once the container finishes building, open a terminal (
Ctrl+`) and run:Follow the sign-in prompt (browser OAuth for a Claude/Anthropic Console account, or your cloud provider's credential chain for Bedrock/Vertex/Foundry).claude - Your
~/.claudeconfig lives in a named Docker volume scoped to this project (claude-code-config-${devcontainerId}), so you won't need to sign in again after a rebuild — only if you delete the volume. - Confirm the isolation is actually in place:
bash .devcontainer/verify.sh
.devcontainer/verify.sh asserts every isolation claim in this README by observing behaviour —
attempting writes that must fail, reaching for hosts that must be unreachable — rather than by
reading config back. Run it after the first build, after any rebuild, and after changing anything
in .devcontainer/. It exits non-zero if a check fails.
bash .devcontainer/verify.sh # all checks
bash .devcontainer/verify.sh --worktree # also probe the git-worktree gap (creates and
# removes a temporary worktree and branch)
It covers container identity (non-root, config volume mounted), privilege containment (no blanket
sudo, firewall script root-owned and unwritable), the read-only .devcontainer mount (create,
append, in-place edit, delete and sudo remount each refused separately, reads still working, and
the rest of /workspace still writable), and network egress (non-allowlisted host blocked,
allowlisted host reachable, DNS up, no IPv6 path around the IPv4 allowlist).
Checks that don't apply to your setup report SKIP and don't fail the run — so if you drop the
firewall or the read-only mount, the script stays useful instead of crying wolf. It distinguishes
"declared in devcontainer.json but not active", which is a real failure and usually means you
haven't rebuilt, from "not declared", which means you opted out.
If you use managed settings, it checks those too, including for
deny rules written against tools whose paths Claude Code never consults. You can validate a
policy file before baking it into the image:
VERIFY_MANAGED_SETTINGS=.devcontainer/managed-settings.json bash .devcontainer/verify.sh
The script lives inside the read-only mount, so nothing running in the container can quietly edit the thing that checks the container.
To adopt the template into a repo that already exists, rather than starting fresh from it:
- Pull in the
.devcontainer/files, tracked so you can re-pull upstream fixes later:git remote add devcontainer-template <this-repo-url> git fetch devcontainer-template git checkout devcontainer-template/main -- .devcontainer .claude/README.md - Add the project's toolchain in the marked block at the bottom of
Dockerfile(see Adding project-specific tools below). - Uncomment the registries and add any other domains the project needs in
init-firewall.sh'sALLOWED_DOMAINS(see Adjusting the firewall). - If the repo already has a
.devcontainer/or.claude/settings.json, merge by hand rather than overwriting — decide whether the no-sudo/firewalled model here is compatible with what's already configured. - Reopen in Container and sign in to Claude Code as in Getting started.
Four choices in here are deliberate and worth keeping if you adapt this:
- No general
sudo. Thedevuser may run exactly one command as root:/usr/local/bin/init-firewall.sh, via aNOPASSWDrule scoped to that path. This matters —mcr.microsoft.com/devcontainers/baseimages grant their default user passwordless sudo for everything, which means anything running in the container cansudo iptables -Fand delete the firewall. The script itself is root-owned and not writable bydev, so it can't be edited into a root shell. ubuntu:24.04rather than a devcontainer base image ornode:20. Claude Code ships as a self-contained native binary and does not use Node at runtime, so a Node base image adds a toolchain and a few hundred MB for nothing. Starting from plain Ubuntu also means no preconfigured user, sudo policy, or shell framework arrives without you asking for it.- Claude Code from Anthropic's signed apt repository. No
curl | bash, no npm, no Node. The Dockerfile verifies the release signing key's fingerprint before trusting the repo, so the build fails loudly if the served key isn't Anthropic's. .devcontainer/is read-only inside the container. The sandbox's own definition isn't editable from within the sandbox. See Protecting the container config below for why this is worth the ergonomic cost.
Known limits of the firewall, so they're not a surprise:
- Outbound DNS (port 53) is unrestricted, because the allowlist is built by resolving names. DNS remains a low-bandwidth exfiltration channel.
- The allowlist is resolved once at container start. If an allowlisted service rotates to new IPs mid-session, it goes dark until you restart the container or re-run the script.
- GitHub is allowed as its full published CIDR range, which is broad by nature — anything you can push to is a place data can go.
The files in .devcontainer/ define the sandbox, so the container shouldn't be able to edit them.
Nothing in there is live at runtime — the firewall actually in force is the root-owned copy at
/usr/local/bin/init-firewall.sh, and the Dockerfile was consumed when the image was built — so
editing the workspace copies changes nothing today. The risk is deferred: a prompt-injected
session appends a domain to ALLOWED_DOMAINS or adds a ~/.ssh mount, and it takes effect the
next time you rebuild. That diff is easy to wave through, because "add a domain to the
allowlist" is the documented normal workflow.
Two layers address this. Reads are deliberately left alone — the files hold no secrets, and Claude needs to read them to explain a blocked request or to add your toolchain to the Dockerfile.
1. Read-only mount (on by default, and the layer that actually enforces). devcontainer.json
re-mounts .devcontainer/ over itself with readonly. The kernel refuses the write, so it holds
against shell commands too, and dev has no sudo or CAP_SYS_ADMIN to remount it.
2. Deny rules (opt-in, see Enforcing policy for a team). These
turn an opaque read-only-filesystem error into a clear refusal, and they also cover the copies of
.devcontainer/ that appear inside git worktrees, which the mount does not. They belong in
/etc/claude-code/managed-settings.json, baked into the image as root — not in
.claude/settings.json, which Claude can edit to lift its own restrictions. Deny rules do still
apply under --dangerously-skip-permissions. Treat them as advisory rather than enforcing: they
block the Edit tool, but a shell command can route around them, which is why layer 1 exists.
What this costs:
- You edit
.devcontainer/from the host, not the attached VS Code window — the editor server runs in the container and is subject to the same read-only mount. Arguably a feature: changing your own sandbox takes a deliberate step outside it. - Git operations that touch
.devcontainer/fail inside the container.git checkout,git pullandgit stasherror withunable to unlink old '.devcontainer/…'if the change spans those paths. Git aborts cleanly and leavesHEADwhere it was. For a project that adopts this template and rarely touches.devcontainer/, you'll never hit it; in this repo, switching between themain,genericandanthropicbranches will.
One residual gap worth knowing. Claude can still edit the .devcontainer/ copy inside a git
worktree and push that branch — the worktree checkout is writable, and only the deny rules in
layer 2 cover it. It cannot land the change in the live path from inside the container, but if you
merge that branch on your host later, it arrives there. The check moves from "the container blocks
it" to "you catch it in review", which is a reasonable place for it as long as you know that's
where it sits.
To opt out entirely, drop the .devcontainer entry from mounts in devcontainer.json.
.devcontainer/verify.sh checks all of this, including that the mount hasn't accidentally been
made too broad — see Verifying the sandbox.
Edit the ALLOWED_DOMAINS array in .devcontainer/init-firewall.sh, then restart the container
(or re-run sudo /usr/local/bin/init-firewall.sh). The script re-runs on every container start
via postStartCommand, since iptables rules don't survive a restart.
Edit it from the host — .devcontainer/ is read-only inside the container
(why).
Package registries (npm, PyPI, Go, crates.io) are commented out by default so each project
opts into its own supply chain — uncomment the ones you need. GitHub is allowed via its published
CIDR ranges; set ALLOW_GITHUB=false in the script to drop it.
Note that the firewall does not apply during docker build, only at container start, so
Dockerfile install steps have unrestricted network access.
If you don't want network restrictions at all, delete postStartCommand/waitFor from
devcontainer.json, drop the NET_ADMIN/NET_RAW entries from runArgs, and remove
init-firewall.sh and its COPY/sudoers step from the Dockerfile.
Claude Code refuses this flag when running as root, which is why remoteUser is the non-root
dev user. With the firewall in place, running:
claude --dangerously-skip-permissions
lets Claude work through multi-step tasks without stopping for approval on every command, while confining what it can reach. Consider auto mode instead if you want fewer prompts without fully disabling safety review.
To stop engineers from bypassing these protections, copy managed-settings.json.example to
managed-settings.json, adjust it, and add to the Dockerfile:
RUN mkdir -p /etc/claude-code
COPY managed-settings.json /etc/claude-code/managed-settings.jsonClaude Code reads /etc/claude-code/managed-settings.json at the highest precedence in its
settings hierarchy, and it lands in the image root-owned, so a session can't edit its own policy
out. The example ships two things:
disableBypassPermissionsMode— blocks--dangerously-skip-permissionsentirely. Drop this key if you want to keep running unattended; the deny rules below work in that mode either way.denyrules for.devcontainer/**and.claude/settings.json, layer 2 of Protecting the container config. Both use**/-prefixed patterns so they match at any depth, which is what covers the copies inside git worktrees.
Write these as Edit(...) rules, not Write(...): Claude Code checks file paths against Edit
and Read rules only, and warns at startup about a path rule for Write.
Note that anyone with write access to this repo can edit that file back out — for policy engineers truly can't bypass, deliver it via server-managed settings or your MDM instead.
Add install steps to .devcontainer/Dockerfile — there's a marked block at the bottom with a
commented example. The image ships only what the firewall and Claude Code need, plus git,
curl and less; language runtimes, linters and CLIs are yours to add. Edit from the host, since
.devcontainer/ is read-only inside the container
(why), then rebuild.
- Anthropic's dev container docs: https://code.claude.com/docs/en/devcontainer
- Network access requirements: https://code.claude.com/docs/en/network-config#network-access-requirements
- Anthropic's own reference container, mirrored on the
anthropicbranch: https://github.com/anthropics/claude-code/tree/main/.devcontainer - Dev Containers spec: https://containers.dev/