A Copier template that injects a fully
configured Python + Claude Code devcontainer into an existing repository —
uv, pre-commit, the GitHub CLI, a zsh/Powerlevel10k shell, a curated set of
Claude Code plugins, and a default-deny network firewall. The firewall is
what makes it safe to run Claude Code with --dangerously-skip-permissions:
outbound traffic is restricted to an explicit allow-list and credentials are
scoped.
It is designed to be applied on top of an existing project (not to scaffold a new one), and to coexist with other Copier templates already used in that project.
Apply the devcontainer to the current repository:
# from the root of an existing repo
uvx copier copy gh:mariushelf/copier-devcontainer "$(pwd)"Copier asks a handful of questions (all with sensible defaults — see below),
writes the .devcontainer/ folder and an .envrc, and records your answers in
.devcontainer/.copier-answers.devcontainer.yml.
Use
"$(pwd)"rather than.so theproject_namequestion is pre-filled with your repository's folder name. (With a bare., Copier can't see the folder name, so you'd type it at the prompt or pass-d project_name=....)
Pull later template improvements into a project you've already set up:
uvx copier update
copier updatecompares released versions, so it only finds updates once this template publishes git tags (e.g.v0.1.0). Until a release is tagged,copier copyworks butupdatereports nothing newer.
| Question | Default | Purpose |
|---|---|---|
project_name |
the destination folder's name | Devcontainer display name; slugified into the Docker Compose project name. |
enable_firewall |
true |
Run the default-deny egress firewall. Disable only if you understand the trade-off (see Design decisions). |
allowed_domains |
Claude Code, GitHub, PyPI, npm, Context7, HuggingFace | The firewall allow-list. Only asked when enable_firewall is true. Remove entries to disallow them; add your project's APIs / mirrors / docs sites. |
gitignore_devcontainer |
true |
Whether to keep the devcontainer out of version control as per-developer setup. |
install_headless_browser |
false |
Bake a headless Chromium + its OS libraries into the image at build time, for Playwright/Puppeteer, Slidev/Marp rendering, or scraping. Built while the network is open so the runtime firewall doesn't have to allow the browser CDN. |
The rendered .devcontainer/ is self-documenting:
.devcontainer/README.md— full tour of the image, lifecycle, volume strategy, and the network firewall..devcontainer/HOWTOS.md— updating the firewall allow-list and rotating the GitHub token..devcontainer/FUTURE_WORK.md— known firewall limitations and candidate hardenings.
An .envrc (direnv) is also written at the repo root; it
puts the host-side helper scripts (dcc — launch Claude Code in the container,
dcexec, dcrebuild, dcdown, dczsh) on your PATH and exports your UID/GID
into the Compose build. It is excluded from version control locally rather than
committed — see Design decisions below.
These are the choices that shape the template; they are intentional, not incidental.
-
Inject, don't scaffold. The template targets existing repositories. It only adds
.devcontainer/and.envrc; it never rewrites project files. -
Per-developer by default, via a self-contained ignore.
.devcontainer/is treated as individual dev setup. Whengitignore_devcontaineris true the template writes.devcontainer/.gitignorecontaining*, so the folder ignores itself — including the Copier answers file — and injecting the devcontainer changes zero tracked files in the host repo (no edit to the repo's root.gitignore). When false, the devcontainer is committed and only.envstays ignored, so secrets are never tracked either way.The two artifacts the template drops at the repo root — the injected
.envrcand the.memsearchindex the memsearch plugin builds at runtime — are kept out of the way in the same spirit:post-create.shadds them to the repo's local.git/info/exclude, never the committed.gitignore, so they don't cluttergit statuswhile still leaving every tracked file untouched. Anything you have deliberately committed under those names is detected and left alone. -
Own answers file for multi-template coexistence. Answers are recorded in
.devcontainer/.copier-answers.devcontainer.yml(not the default.copier-answers.yml), so this template can be applied alongside a project's primary Copier template without their answer files colliding. -
Parameterized container name, derived from the environment. The previously hard-coded name is now
project_name, defaulting to the destination directory's basename (_copier_conf.dst_path.name). It feeds both the devcontainer display name and the slugified Docker Compose project name so host helpers and the devcontainer CLI converge on one project. -
Firewall allow-list is data, not code. The allow-list is a Copier answer rendered into
init-firewall.sh, pre-populated with only generic tooling hosts. Projects add their own domains at copy time instead of editing the script. The firewall itself defaults on (enable_firewall: true) but can be turned off at copy time for projects that don't want it — e.g. an already network-isolated host, or Claude Code run without--dangerously-skip-permissions. Disabling it only no-opspostStartCommandand drops theNET_ADMIN/NET_RAWcapabilities; the firewall script and its sudoers grant stay baked into the image, so re-enabling later is just flipping the answer back. -
Narrow templating surface.
_templates_suffix: .jinjameans only the few files needing variables are rendered through Jinja; everything else is copied verbatim, so brace-heavy files (e.g..p10k.zsh) pass through untouched. -
Update is best-effort when ignored. With the devcontainer git-ignored there is no committed baseline, so
copier update's three-way merge degrades toward overwrite — local hand-edits to generated files are more fragile. This is an accepted trade-off for per-developer setup. Choosegitignore_devcontainer: falseif you want robust team-shared updates. -
Project setup lives in two developer-owned hook scripts, not the template. Anything project-specific — extra tools, Claude Code plugins, MCP servers, first-run steps — goes in one of two scripts the template renders once and then never overwrites (
_skip_if_exists), so they are yours to edit and acopier updatepulls wiring improvements around them without clobbering their contents:.devcontainer/custom-build.shruns at image-build time (the lastRUN, as thedevuser) for things worth baking into a cached layer: extra CLIs, language servers, the binaries your plugins expect onPATH. It is last because it churns most and Docker only rebuilds layers after the first change;uv tool installlands on Claude'sPATH, while root-only system packages stay in the Dockerfile's apt block..devcontainer/custom-post-create.shruns at container-create time (invoked bypost-create.sh) for steps that need the running container — above all Claude Code plugins / MCP servers, which live in the~/.clauderuntime volume that does not exist at build time.
A deliberate non-goal: the template does not resolve or manage these for you (no manifest-to-installer machinery, no JSON plugin manifest). Direct
uv/claudecommands in a script you own are simpler and more transparent than reinventing apt/uv/npm behind a leakier interface. -
Host-side launchers self-locate; they don't require git. The
bin/helpers (dcexec,dcrebuild,dcdown, ...) resolve the project root from their own path (two levels up from.devcontainer/bin/), resolving symlinks by hand so they work when invoked directly, via the.envrc-addedPATH, or through a hand-rolled symlink. They deliberately do not shell out togit rev-parsefor the root: that made a git binary and a git working tree a precondition for merely starting the container, which broke in exported tarballs and non-repo checkouts.scripts/test-launchers.shguards this by driving each launcher with git shimmed to fail, from a non-repo directory.
The template has two test suites, both driven by make so they run identically
locally and in CI:
make test # everything
make test-render # fast, no Docker
make test-launchers # fast, no Docker — bin/ launchers resolve the root without git
make test-devcontainer # builds & boots the rendered containermake test-render renders the defaults, the gitignore_devcontainer=false
variant, a messy project_name (asserting the Compose name is a valid
lowercased slug), emptied/custom allowed_domains lists, and both
install_headless_browser settings. For each render it checks that
project_name and allowed_domains flow into the right files, the .gitignore
matches the ignore choice, the Dockerfile's headless-browser block is present
only when requested, rendered shell passes bash -n, devcontainer.json is
valid JSONC, docker-compose.yml is valid YAML, and no unrendered Jinja
remains. It also runs a copier update round-trip. No Docker needed — a passing
render does not prove the image builds, only that it renders correctly.
make test-devcontainer is the live check: it renders the template and uses
the devcontainer CLI to up the
rendered container (full real-session lifecycle, including the post-start
firewall), execs claude --version, and probes that the firewall blocks
out-of-allowlist egress while an allowed host stays reachable. It needs Docker,
node/npx, and a GitHub token ($GH_TOKEN, or gh auth login). It's slow (full
image build plus post-create plugin installs and model warm-up).
CI runs on GitHub Actions in two jobs that call these same targets — no separate CI code path:
render— runsmake test-renderon every push and PR.devcontainer— runsmake test-devcontaineron PRs (and manual dispatch) only; its cost is why it doesn't run on every push. To block merges on it, markdevcontainera required status check in the branch-protection rule formain.
- uv (to run
uvx copier) or Copier installed directly. - To actually run the rendered devcontainer: Docker + Docker Compose and an IDE with devcontainer support (or the devcontainer CLI).
See LICENSE.