Reusable Dev Container Features, a host-side Taskfile for driving the devcontainer CLI from Windows without installing Node.js, and a one-line bootstrap that wires both into any repo.
| Feature | What it installs |
|---|---|
go-task |
Task (task), sha256-verified from GitHub releases, with bash/zsh completions. |
devcontainer-cli |
@devcontainers/cli as devcontainer, on a private Node.js under /opt/devcontainer-cli that is never put on PATH. |
claude-code |
Claude Code: the native release binary (no Node.js, version pinnable) unless a claude is already there (the official Anthropic Feature), plus the claude-host launcher and its state (CLAUDE_CONFIG_DIR) in a volume it owns. |
git |
git augmented for the container: a worktree-aware wrapper that scopes GIT_DIR to the checkout, safe.directory and core.hooksPath at post-create, the global config in a volume it owns. |
gh |
GitHub CLI from GitHub releases, checksum-verified, unless the official Feature installed it; its login (GH_CONFIG_DIR) in a volume it owns, git's GitHub credentials taken from it. |
az |
Augments the official azure-cli Feature: its login (AZURE_CONFIG_DIR) in a volume it owns, az bicep using the Bicep on PATH. |
kube-state |
Owns the kubeconfig (KUBECONFIG) the Kubernetes tools share, in a volume it owns, so velero and kubectl stop needing a hand-mounted ~/.kube; installs no binary. |
devhost |
The way of working as a Feature: one host Claude Code session per repo, backs that keep the ledger, backlog and docs from Claude Code hooks, the session briefing, cost report, ADR tool, verify guards, and devhost-init to scaffold a repo. |
common |
The shared helper behind every Feature's post-create hook (dcf-persist: make a state directory usable and say what backs it), for a repo's own scripts too. |
coder-cli |
Coder CLI from GitHub releases, verified against the release checksums. |
openbao |
OpenBao CLI (bao), verified against the release checksums. |
velero |
Velero CLI, verified against the release CHECKSUM. |
kustomize |
Standalone kustomize, verified against the release checksums. |
kubeconform |
kubeconform manifest validator, verified against the release CHECKSUMS. |
argocd |
Argo CD CLI, verified against the release cli_checksums.txt. |
dsc |
Microsoft DSC v3 in /opt/dsc, verified against the GitHub-published asset digest (or a pinned sha256). |
Every Feature that installs a tool takes a version option (latest or a
pinned release) and verifies what it downloads before installing it, with one
exception: devcontainer-cli verifies its private Node.js but not the npm
package of the CLI itself (see its notes).
The collection is an ecosystem, not a bag of installers. Every repo that uses it gets the same shape, and anything a repo has to script around is a gap to close here (see Contributing from a consuming repo).
- Task is the API, on both sides of the container wall. On the host,
task devcontainer:*(vendored include, aliasdc:) builds, starts, enters and talks to the container; inside, every Feature contributes<id>:*tasks through the go-task registry. Documents and skills name tasks, never the scripts behind them. - Augment, don't duplicate. When an official Feature exists for a tool
(Anthropic's
claude-code,devcontainers/features/git), this collection's Feature of the same id orders itself after it (installsAfter) and adds what the ecosystem needs around it: a launcher, a post-create hook, a Taskfile. It installs the tool itself only when nothing else did, and then verified and pinnable. Wrappers are opt-in and generic (no repo paths). - The checkout is the only thing on disk; each Feature owns its state.
Everything that is not the repo is either persistent somewhere a Feature
manages, or ephemeral inside the container. A Feature that has state
(logins, transcripts, tool config) declares the variable that points at it
and a named volume on
/var/lib/devcontainer-features/<id>, like the official docker-in-docker Feature does, and its post-create hook makes the directory usable by the remote user (dcf-persist, fromcommon). Nothing to configure. A repo that wants that state on the host disk (survives container-engine resets, follows the machine) bind-mounts the same target, its mount wins, andtask devcontainer:homesasinitializeCommandguarantees the host folder exists. Examples spell the host path with${localEnv:USERPROFILE}(Windows); on Linux/macOS use${localEnv:HOME}. Never concatenate the two: both can be set on Windows. Caches and worktrees stay out of the checkout too (PYTHONPYCACHEPREFIX, a volume over.claude/worktrees), or a synced checkout carries every build artefact. - One session per role, found by UUID.
claude-hostresumes the session inHOST_SESSION(from.devcontainer/.env) wherever it runs;task devcontainer:claudedoes it from the host,task claude-code:hostinside,DIR=<sub-folder>for a session per sub-folder. - Settings are plain variables, three ways. Command-line var, then the
.envfiles, then the exported environment, with the same names in the tasks, the PowerShell scripts and their bash twins. - Windows only bootstraps and launches; the work happens inside. The
host needs PowerShell 7, Task and a Docker-compatible engine (
dockerorwslc), nothing else: Node.js and the devcontainer CLI are cached per user, and every tool a project needs is a Feature. Sessions, tests and builds run in the container. Linux/macOS get a bash twin of every host-side script.
Each Feature also ships a Taskfile for its tool (dsc:get, velero:backups,
kustomize:build:all, argocd:sync, ...). With the default
installTaskfile: true it is registered at build time under
/usr/local/share/go-task/includes.d/, and the go-task Feature keeps
/usr/local/share/go-task/features.yml including all of them. One include in
the root Taskfile, never edited again:
includes:
features:
taskfile: /usr/local/share/go-task/features.yml
optional: true
flatten: trueAdd a Feature and rebuild: its tasks appear. Remove it: they disappear.
bootstrap/bootstrap.ps1 / bootstrap.sh write that include into the starter
Taskfile.yml.
"features": {
"ghcr.io/nabil-becker/devcontainer-features/go-task:1": {},
"ghcr.io/nabil-becker/devcontainer-features/devcontainer-cli:1": {},
"ghcr.io/nabil-becker/devcontainer-features/git:1": { "hooksPath": ".githooks" },
"ghcr.io/nabil-becker/devcontainer-features/claude-code:1": {},
"ghcr.io/nabil-becker/devcontainer-features/kustomize:1": { "version": "5.8.3" }
}Not in this collection on purpose: the GitHub Copilot CLI (now an official
ghcr.io/devcontainers/features/copilot-cli), code-server (Coder publishes
ghcr.io/coder/devcontainer-features/code-server), and shellcheck/yamllint
(ghcr.io/devcontainers-extra/features/*).
To try an unpublished change, reference the Feature folder locally
("./features/<id>": {}); the CLI only accepts local Features that live
under the .devcontainer/ folder, so copy or link it there.
Windows is the primary host (PowerShell 7 + Task + Docker Desktop or
wslc.exe); Linux and macOS are supported through a bash twin of every
host-side script. From the repo root:
# Windows
irm https://raw.githubusercontent.com/nabil-becker/devcontainer-features/main/bootstrap/bootstrap.ps1 | iex# Linux / macOS
curl -fsSL https://raw.githubusercontent.com/nabil-becker/devcontainer-features/main/bootstrap/bootstrap.sh | bashbootstrap/bootstrap.ps1 / bootstrap/bootstrap.sh:
- vendor
bootstrap/devcontainer.yml,bootstrap/host/*(the PowerShell flavour and the bash twins) and thedevcontainer-cliFeature'sdevcontainer-cli.shinto the repo'sbootstrap/folder - always refreshed, so re-running is the update path; - write
Taskfile.yml(includes the vendored tasks underdevcontainer:with the aliasdc:),.devcontainer/devcontainer.json(go-task, devcontainer-cli, git, docker-outside-of-docker, PowerShell) and.devcontainer/.env.examplefrombootstrap/- only if they do not exist yet - and gitignore.devcontainer/.env; - run
task devcontainer:init(caches the portable Node.js + CLI, reports the engine) andtask devcontainer:up.
That breaks the circular dependency: the repo never needs a checkout of this
one, and from then on every devcontainer operation goes through
task devcontainer:* (or task dc:*).
Every setting of the bootstrap and of the tasks can be given three ways. The
bootstrap scripts resolve explicit parameter, then environment variable, then
.env file (./.env or ./.devcontainer/.env). The tasks resolve as Task
does: a var on the command line (task dc:up DEVCONTAINER_DOCKER_PATH=docker),
then the .env files (loaded by dotenv: in the generated Taskfile), then
the exported environment. That is how a piped irm | iex or curl | bash
run is configured:
| Setting | Env var | Parameter |
|---|---|---|
Docker-compatible CLI for --docker-path (docker, wslc, podman, nerdctl, finch, or a path). Unset: docker on PATH, else wslc.exe on Windows. |
DEVCONTAINER_DOCKER_PATH |
-DockerPath / --docker-path |
| Portable Node.js version | DEVCONTAINER_NODE_VERSION |
- |
@devcontainers/cli version (default latest) |
DEVCONTAINER_CLI_VERSION |
- |
Root of the host-side tool-state folders (devcontainer:homes; default ~/.devcontainer-homes) |
DEVCONTAINER_HOMES |
- |
UUID of the Claude Code session devcontainer:claude resumes (HOST_SESSION_<DIR> per sub-folder) |
HOST_SESSION |
SESSION= var |
Git ref to vendor from (default main) |
DEVCONTAINER_BOOTSTRAP_REF |
-Ref / --ref |
| Source URL base or local checkout | DEVCONTAINER_BOOTSTRAP_SOURCE |
-Source / --source |
| Repo root (default cwd) | DEVCONTAINER_BOOTSTRAP_PATH |
-Path / --path |
Stop before up |
DEVCONTAINER_BOOTSTRAP_NO_UP=true |
-NoUp / --no-up |
$env:DEVCONTAINER_DOCKER_PATH = 'wslc'; irm https://raw.githubusercontent.com/nabil-becker/devcontainer-features/main/bootstrap/bootstrap.ps1 | iex
# or with parameters
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/nabil-becker/devcontainer-features/main/bootstrap/bootstrap.ps1))) -DockerPath wslc -NoUpSee bootstrap/env.example for a commented .env.
Each Feature stores its persistent state (logins, config, transcripts) in a named volume by default. To keep a Feature's state on the host disk instead (survives container-engine resets, stays outside a synced checkout), uncomment and configure the host-disk block in .devcontainer/devcontainer.json: it binds the same target path on both the host and inside the container, and the repo's mount wins over the Feature's volume.
| Task | Does |
|---|---|
devcontainer:init |
Cache portable Node.js + @devcontainers/cli, report engine. FORCE=true reinstalls the CLI. |
devcontainer:homes NAME=<slug> -- <dir>... |
Create ~/.devcontainer-homes/<slug>/<dir> on the host, for a repo that bind-mounts a Feature's state target to the host disk instead of the Feature's volume; meant as initializeCommand (array form: ["task", "devcontainer:homes", "NAME=myrepo", "--", "claude-code"]). HOST_GIT_DIR=<path> also checks a git dir kept outside the checkout. |
devcontainer:up / rebuild / build |
devcontainer up (extra flags after --), from-scratch rebuild, image build only. |
devcontainer:exec CMD='...' / shell / pwsh |
Run a command (base64-safe), an interactive bash, or PowerShell in the container. |
devcontainer:claude [DIR=<sub-folder>] [SESSION=<uuid>] [-- <claude args>] |
Start or resume the Claude Code host session inside (needs the claude-code Feature): HOST_SESSION from .devcontainer/.env, HOST_SESSION_<DIR> for a sub-folder. Runs directly when already inside the container. |
devcontainer:config |
Resolved configuration. |
devcontainer:<anything> |
Passthrough to that CLI subcommand, args after --. |
devcontainer:reset-storage |
wslc.exe only: reset WSL Containers' storage after a read-only BuildKit error. |
All of them also answer to dc: (the include's alias in the generated
Taskfile). Each task dispatches per platform (Task's per-command
platforms):
- Windows runs
bootstrap/host/Invoke-DevcontainerCli.ps1: a pinned portable Node.js is downloaded once (sha256-verified) into%LOCALAPPDATA%\devcontainer-features\devcontainer-node, the CLI is npm-installed next to it, nothing lands on PATH. Needs PowerShell 7.homesrunsInitialize-DevcontainerHomes.ps1. - Linux/macOS run
devcontainer-cli.sh, the very script that installs thedevcontainer-cliFeature (vendored asbootstrap/host/devcontainer-cli.sh):devcontaineron PATH if present, else the same portable Node.js trick under~/.cache/devcontainer-features. Needs bash and curl.homesrunsdevcontainer-homes.sh.
Both honour the settings above. The include must be a local include - the
tasks run the scripts next to the Taskfile, which remote includes cannot
reach. It declares no global env: on purpose: with Task 3.45 an env: key
in an include claims the name and the .env value is discarded for the
whole project (the bug the first consuming repos worked around with a
dc:* passthrough; that workaround is no longer needed).
A repo that needs something its devcontainer does not provide asks here first: an issue for a missing Feature, option, task or host script, then a pull request following CONTRIBUTING.md. A local wrapper in the consuming repo is a stopgap that names the upstream issue it waits on, and goes away when the next release ships the functionality. Two things are not accepted upstream and stay local: anything that embeds the repo's own paths or names, and anything only one repo has ever wanted.
task test # devcontainer features test, all Features
task test FEATURE=go-task # one Feature
task test:global # combined scenario in test/_global
task lint # shellcheck + PSScriptAnalyzer
task docs # regenerate src/<id>/README.md (commit the result)devcontainer features test does not run on a Windows host (the CLI shells
out to chmod/bash), so task test is gated to Linux/macOS. The easiest
place is this repository's own devcontainer (.devcontainer/: docker-in-docker,
shellcheck, PowerShell, and the collection's go-task and devcontainer-cli
Features); otherwise let CI do it.
Layout follows devcontainers/feature-starter:
src/<id>/devcontainer-feature.json + install.sh + NOTES.md, with
README.md generated from those two by task docs and checked by CI, and
test/<id>/test.sh + scenarios.json.
Run the Release dev container features workflow (workflow_dispatch on
main, release environment). It publishes every src/<id> to
ghcr.io/nabil-becker/devcontainer-features/<id>; it has no write access to
the repository, since READMEs are committed with each change rather than
generated at release time. Bump version in the Feature's
devcontainer-feature.json before releasing a change.
After the first release, GHCR packages are private by default: open each package's settings on GitHub, change visibility to Public, and link it to this repository. Otherwise consumers (and the containers.dev crawler) cannot pull it.
The collection is meant to be listed in the
containers.dev index. Once published,
open a PR against
_data/collection-index.yml
in devcontainers/devcontainers.github.io adding:
- name: Dev Container Features by nabil-becker
maintainer: nabil-becker
contact: https://github.com/nabil-becker/devcontainer-features/issues
repository: https://github.com/nabil-becker/devcontainer-features
ociReference: ghcr.io/nabil-becker/devcontainer-featuresListed collections are crawled for liveness and surface in VS Code and Codespaces, so the repo is run as a public project: see CONTRIBUTING.md, SECURITY.md and CODE_OF_CONDUCT.md.
mainis protected by theprotect-mainruleset: pull requests only, one approval including a code owner, conversations resolved, required checksvalidate/lint/tests/DCO, squash merges, no force-push. Repo admins may bypass only through a PR.- Every commit is signed off under the Developer Certificate of Origin;
task repo:setupmakes that automatic per clone (see CONTRIBUTING). - Workflows run with a read-only
GITHUB_TOKEN; the release job alone getspackages: write, inside thereleaseenvironment restricted tomain. Actions are SHA-pinned and limited to GitHub-owned ones plusdevcontainers/action; Dependabot bumps them weekly. - Secret scanning with push protection, Dependabot alerts and private vulnerability reporting are on.
All of this is applied by task repo:govern
(.github/scripts/Set-RepoGovernance.ps1),
which is idempotent, so settings drift can be corrected by re-running it.