Shared devcontainer Features for RTLDEV
middleware repositories, published to ghcr.io and consumed by version rather than
copied.
| Feature | What it provides |
|---|---|
devbase |
zsh with the team prompt, commitizen, pnpm, the gh credential helper, persistent shell history, dependency installation, attach banner |
Add one entry to your repository's .devcontainer/devcontainer.json:
Only the second line is strictly required. devbase declares node (at lts),
github-cli and claude-code in dependsOn, so it installs all three itself, before
itself. The node entry above is kept because it deduplicates with devbase's own — same
feature, same options, one install — and it documents the runtime at the point people look
for it. Other runtimes (php, go, python, java) are genuinely yours: devbase installs
none of those and only orders itself after them.
Rebuild the container. That is the whole integration for a repository whose defaults are fine.
Starting a repository from scratch? Copy a devcontainer frame from rtldev-middleware-template instead — the frames come with this entry already in place.
Contents
- The
devbaseFeature - Why a Feature and not copied files
- Options
- Configuring the attach banner
- Migrating a repository that already has a devcontainer
- Developing a Feature
- Publishing
- Keeping consumers up to date
- Troubleshooting
devbase is the shared behaviour half of our devcontainer setup. It installs no
language runtime of its own — php, go, python and java stay in each repository's feature
list, and devbase declares installsAfter for them so its setup steps run once those
runtimes exist. Node is the exception, and a deliberate one: see below.
Three features it does not leave to the consumer, declared in dependsOn rather than
installsAfter: github-cli, because the gh credential helper below is useless
without gh; claude-code, because it is on every one of our machines anyway; and
node at lts, because devbase's own pnpm and commitizen steps need npm, and
because claude-code installs Node 18 from nodesource — EOL since April 2025 — when it
cannot find a Node of its own. installsAfter is only a hint: it orders a feature the
consumer already listed and does nothing when they did not, so it could never have carried
these three.
On the Node pin. Keep
"ghcr.io/devcontainers/features/node:2": { "version": "lts" }in yourdevcontainer.jsonif it is already there — identical options deduplicate, so it collapses intodevbase's own entry and changes nothing. It is now optional rather than required.What you cannot do is pin a different version. Differing options do not deduplicate: both instances install,
devbase's runs second, and itsdefault -> lts/*replaces yours — a repository asking for 22 measurably ended up on 24. Sodevbaseowns the Node version for every consumer. If a repository genuinely needs another major, that is a change todevbase'sdependsOnand a release, not something to arrange locally. Thenode_pinnedscenario asserts the override so it stays visible rather than surprising.A knock-on: the Node feature ships
pnpmVersion: latest, so pnpm is present in every container beforedevbaseruns. TheinstallPnpmoption therefore decided nothing and has been removed — pnpm is now simply part of what you get. Nothing consumes this Feature yet, which is the only reason dropping an option did not need a major.
What it does, on first create and on every attach:
- zsh with the team prompt (git status segment, history search, autosuggestions)
- commitizen plus
cz-conventional-changelog, and the matching.czrc - pnpm, installed globally, with
PNPM_HOMEonPATH ghcredential helper wired into the workspace's git config- Persistent shell history across container rebuilds
- Dependency installation —
composer.jsonvia composer,package.jsonvia pnpm,.envseeded from.env.example - A UTF-8 locale (
LANG=C.UTF-8) for the whole container, not just interactive shells - An attach banner reporting the container, language and dependency versions
- The shared VS Code extension set, the zsh terminal profile and a few editor
settings — shellcheck,
npm.packageManager, andfiles.excludehiding**/node_modules
On overriding the VS Code settings. A setting you declare in your own
devcontainer.jsonwins over the Feature's, and for an object-valued setting that is a whole-object replacement, not a deep merge — VS Code does not merge object values across settings scopes.
files.excludeis the one this bites. A repository that adds**/vendorby declaring its ownfiles.excludesilently loses the inherited**/node_modulesentry, because its object replaces the Feature's rather than extending it. Restate what you want to keep:"customizations": { "vscode": { "settings": { "files.exclude": { "**/node_modules": true, // restate, or you lose it "**/vendor": true } } } }Which also means there is no separate opt-out to build:
"**/node_modules": falseun-hides it, and so does simply leaving the key out of your own object.
On the locale. Our base images ship no
LANG, which leaves the C library in theClocale with an ASCII charmap — inherited by every tool that reads a file, so any source file with an em-dash in a comment decodes wrong somewhere. The Feature declarescontainerEnv: { "LANG": "C.UTF-8" }, which the CLI bakes into the image as anENV, so it reaches non-interactive processes and lifecycle commands too — a/etc/profile.dsnippet would not.
C.UTF-8is built into glibc (nolocalespackage, nolocale-gen) and collates by codepoint exactly asCdoes, sosortand[a-z]ranges in existing scripts are unaffected. Only the charmap changes.There is no option for it, on the same reasoning that keeps
mountsunused:containerEnvis static JSON with no option substitution, so a flag could not switch it off and would only be a lie. SetcontainerEnv.LANGin your owndevcontainer.jsonto override — the CLI emits the consumer'scontainerEnvafter the feature layer, so it wins — and make sure the locale you name exists in the image, becausesetlocalefalls back toCin silence when it does not.
Because the container frames differ across our repositories and the behaviour does not.
php-sdk and mcp-dis build a single container from a Dockerfile; whmcs-src runs its
dev container as one service in a four-service compose stack. No shared
devcontainer.json or Dockerfile spans those. What was shared, before this
Feature existed, was the behaviour — and it had been copy-pasted into each repository
and then drifted: the log_*/execute_with_indent/setup_pnpm block existed in
three near-identical copies, .zshrc differed by 29 lines between two repositories
for no reason anyone chose, and the attach banner had been reinvented three times.
A Feature installs into a container built either way, is versioned, and is pinned by
digest in each consumer's devcontainer-lock.json. Copied files are none of those things.
Getting a new version into that lock is its own problem — see
Keeping consumers up to date.
All optional; the defaults are what php-sdk and mcp-dis want.
| Option | Default | What it does |
|---|---|---|
commonPackages |
true |
Installs wget jq git zip unzip curl zsh shellcheck. Turn off for a base image that already has them. |
timezone |
Europe/Berlin |
Written to /etc/localtime and /etc/timezone. Empty string leaves the image alone. |
globalPackages |
commitizen@latest,cz-conventional-changelog@latest |
Comma-separated global pnpm installs. Empty installs nothing. |
zshAutosuggestions |
true |
Installs the zsh-autosuggestions plugin. |
historyPersistence |
true |
Symlinks ~/.zsh_history to /WSL_USER/.zsh_history, creating the host file if it does not exist yet. Needs the host home mounted at /WSL_USER; skipped silently if not. |
ghCredentialHelper |
true |
Points the workspace git credential helper at gh auth git-credential. |
sshCommitSigning |
true |
Repairs SSH commit signing when a mounted host ~/.gitconfig points user.signingkey at a key file the container lacks; uses the forwarded ssh-agent and writes an inline key:: key to the workspace git config. Never enables signing you did not configure. |
envInfoBanner |
true |
Prints the toolchain banner on attach; also installs devbase-env-info. |
installProjectDependencies |
true |
Installs from composer.json / package.json and seeds .env from .env.example. |
autoloadEnvScript |
true |
Sources a workspace env.sh from ~/.zshenv so new terminals inherit it. |
installRtk |
true |
Installs RTK, the token-optimizing CLI proxy for Claude Code. Binary only — the hook stays in the mounted ~/.claude. |
rtkVersion |
0.45.0 |
RTK release to install, without the leading v. Checksum-verified against the release's checksums.txt. |
A stack elaborate enough to own its own setup turns the generic part off:
"ghcr.io/centralnicgroup-opensource/rtldev-middleware-devcontainer-features/devbase:1": {
"installProjectDependencies": false,
"timezone": "Europe/London"
}Two things worth knowing about consuming it:
- Pin the major (
:1), not a patch. A new1.xis picked up on the next rebuild, and the digest recorded indevcontainer-lock.jsonkeeps the build reproducible in between. - Lifecycle order. The Feature's
postCreateCommandandpostAttachCommandrun before the ones in yourdevcontainer.json, so your own hooks can rely on pnpm, the global packages and your dependencies already being installed. Put repository-specific setup there — never fork the Feature's scripts.
RTK is a token-optimizing CLI proxy for Claude Code: a
PreToolUse hook rewrites shell commands and filters their output, cutting a large share
of the tokens tool results otherwise consume.
It is installed here, rather than in each repository's Dockerfile, because of an
asymmetry (RSRMID-2933). The hook lives in ~/.claude/settings.json, which every frame
bind-mounts from the host — so the configuration is shared between host and container
while the binary is not. A container without rtk fires a hook that exits 127 on
every Bash call: no savings, plus an error each time. Installing it centrally means the
binary follows the hook into every repository instead of being re-pasted into each one.
RTK needs two halves, and they are centralised in different places:
| Half | Lives in | Why there |
|---|---|---|
the rtk binary |
this Feature | it must exist inside the container, which is what the Feature builds |
the PreToolUse hook that calls it |
the consuming repository's committed .claude/settings.json |
it is configuration: reviewable, versioned, and identical for everyone on the team |
The hook is not written by this Feature, and that is deliberate. The only per-user
settings file in the container is ~/.claude/settings.json, which every frame bind-mounts
from the host — writing it would edit the developer's own workstation configuration from
inside a container, and rtk init -g is never run for the same reason.
Leaving the hook to each developer's personal ~/.claude was the other option, and it is
what the original arrangement did. It means RTK is active for whoever configured it by hand
and inert for everyone else, which is the team-wide-versus-personal problem restated rather
than solved. So the hook belongs in the repository, and
rtldev-middleware-template
ships it in .claude/settings.json:
{
"type": "command",
"command": "command -v rtk >/dev/null 2>&1 && exec rtk hook claude || exit 0",
"statusMessage": "Optimizing command output (RTK)"
}The guard is the load-bearing part. That one committed file is read on the host, in CI
and inside the container, but only the container is guaranteed to have rtk. Unguarded, it
would exit 127 on every Bash call made outside the container — the same defect as a
container with the hook but no binary, pointing the other way. Guarded, it activates
precisely where the Feature has installed the binary and is a silent no-op everywhere else.
Remove the personal copy. If you already have
rtk hook claudein your own~/.claude/settings.json, delete it once a repository carries the hook. Hooks from user and project settings both fire, and this one returns anupdatedInputthat rewrites the command — two hooks rewriting the same tool call is not a defined outcome.
- Only the binary.
rtk init -gis never run — it would rewrite the bind-mounted~/.claude/settings.json, which is shared with the host. - Pinned and checksum-verified. The version is an option, and the download is checked
against the release's own
checksums.txt, so a curl-fetched third-party binary is not an unverified supply-chain surface. - Fails the build rather than warning. RTK is opted into; continuing without it leaves
a hook erroring on every Bash call, which is harder to diagnose than a build that stops
and says why. Use
"installRtk": falseto opt out — per repository, or per developer via a local config.
Upstream publishes a musl build for x86_64 but only a gnu build for aarch64, so
the two architectures do not share a naming pattern; install.sh maps uname -m to the
right asset and skips with a warning on an architecture with no published build.
The banner shows the installed version, which is where "hook configured but binary missing" becomes visible.
With no configuration the banner titles itself from the repository directory name and
shows each language group whose runtime is present. To override, add
.devcontainer/env-info.conf to the consuming repository:
TITLE="PHP-SDK - development environment"
SHOW_PHP=auto # auto | true | false, likewise SHOW_NODE/GO/PYTHON/JAVA
PHP_EXTENSIONS="curl intl xdebug"
PHP_NOTE="(language-feature ceiling: 8.3)"
NODE_DEPS="@modelcontextprotocol/sdk express zod"
COMPOSER_DEPS=""
EXTRA_ROWS="Apache|apache2 -v|3
MariaDB|mariadb --version|"The file is sourced as shell, so quote values containing spaces. EXTRA_ROWS takes one
Label|command|field-index per line, where the field index picks a whitespace-separated
field (1–3) and defaults to the whole first line of output.
Run devbase-env-info to see the result without reattaching.
Dependency versions are read from node_modules/ and vendor/, never from the
manifest, so an empty row means "install has not run" rather than "unknown" — the
distinction that makes the banner worth reading.
Roughly 30 minutes per repository. Work on a branch and rebuild before you delete anything.
-
Add the Feature to
devcontainer.json'sfeaturesblock. -
Delete what it replaces. For a repository on the php-sdk/mcp-dis pattern, that is the whole shared
supporting_files/tree:.devcontainer/supporting_files/scripts/post-create.sh .devcontainer/supporting_files/scripts/post-attach.sh .devcontainer/supporting_files/scripts/env-info.sh .devcontainer/supporting_files/configuration/home/.zshrc .devcontainer/supporting_files/configuration/home/.czrcKeep anything genuinely repository-specific — php-sdk's
phpunit-wrapper.shand itsphp/*.inifiles, for instance. -
Strip the Dockerfile down to the base image plus whatever this repository actually needs. The
apt-getblock, timezone lines,usermod --shell, and theCOPYof the zsh/commitizen config are all the Feature's job now. -
Repoint the lifecycle commands. Delete
postCreateCommandandpostAttachCommandif they only ran the shared scripts. If the repository has its own setup, keep apostCreateCommandfor just that part. -
Generalise the workspace paths (single-container frames only):
"workspaceMount": "source=${localWorkspaceFolder},target=/usr/share/${localWorkspaceFolderBasename},type=bind,consistency=cached", "workspaceFolder": "/usr/share/${localWorkspaceFolderBasename}"
Compose frames keep the literal path, because compose resolves its volumes independently and the two must agree.
-
Move the banner content from the deleted
env-info.shinto.devcontainer/env-info.conf. -
Trim the extension list. The Feature contributes the shared six; delete those from the repository's list and keep only the language-specific ones.
-
Rebuild, then check: the prompt renders,
devbase-env-inforeports the right versions,cz --versionworks,git pushauthenticates throughgh, and shell history survived the rebuild. -
Keep a
~/.zshrc.localif you had personal shell additions in the old.zshrc— the Feature's copy is overwritten on rebuild by design, and.zshrc.localis sourced at the end and never touched.
This repository's own devcontainer consumes devbase from the registry, exactly as a
consumer does — so this environment is a standing check that the published artifact
works. The working tree is exercised by the test suite instead:
pnpm features:test # default options + every scenario, real builds
pnpm features:test -- --filter minimal # one scenario, while iterating
pnpm features:lint # shellcheck + metadata validation
pnpm lint:workflows # actionlint over .github/workflows
pnpm lint # all of the above plus prettierpnpm lint runs the same checks CI does, from the same scripts, so a green run locally
means a green run in CI. lint:workflows is the one that cannot be left to CI alone: an
invalid workflow file never starts, so the actionlint job that would report it is one of
the jobs that does not run. It downloads a pinned, checksum-verified actionlint into
.cache/ on first use unless one is already on PATH.
pnpm features:test builds real containers straight from features/src/, so it is the
iteration loop — no publish, no copy, nothing to keep in sync. It needs a Docker daemon,
which is why the devcontainer includes docker-in-docker.
Occasionally you want your own environment built from the branch. There is a second
config for that — .devcontainer/local/devcontainer.json, offered by VS Code's config
picker as "working tree":
pnpm devbase:local # copy features/src/devbase -> .devcontainer/local/devbase
# then rebuild, choosing the "working tree" config
pnpm devbase:local:check # is the copy still current?
pnpm devbase:local:cleanRe-run pnpm devbase:local after each edit; forgetting is the one hazard of a copy, and
the reason this is the exception rather than the default. That config's
initializeCommand now runs the check for you and fails the build on a stale copy,
because the failure is otherwise unrecognisable as one: a copy predating dependsOn,
in a config that has stopped listing what dependsOn supplies, yields a container with
no node, npm, pnpm, gh or claude — while zsh and rtk are present, because
the old copy installs those itself. It reads as a broken registry rather than a missing
cp.
The copy is not laziness — the devcontainer CLI leaves no better option, and all three alternatives were measured:
| Reference | Result |
|---|---|
"../features/src/devbase" |
Refused: "Local file path parse error. Resolved path must be a child of the .devcontainer/ folder." |
symlink at .devcontainer/devbase |
Passes the path check, then fails the fetch step — the CLI needs a real directory |
real copy inside .devcontainer/ |
Works |
Copying it in from initializeCommand does not help either: features are resolved
before initializeCommand runs. The alternate config lives at .devcontainer/local/
precisely so its "./devbase" resolves inside .devcontainer/ and is accepted.
Until devbase:1 has been published once there is nothing for the default config to pull.
Run the Publish features (manual) workflow once, or use pnpm devbase:local, before
building the default container on a fresh repository.
Layout:
Layout:
features/
├── src/devbase/
│ ├── devcontainer-feature.json # id, version, options, extensions, lifecycle hooks
│ ├── install.sh # build-time, as root, no workspace yet
│ ├── bin/
│ │ ├── devbase-post-create.sh # first create, as the user, workspace mounted
│ │ └── devbase-post-attach.sh # every attach
│ ├── lib/
│ │ ├── log.sh # logging + execute_with_indent
│ │ ├── setup.sh # the shared setup steps
│ │ └── env-info.sh # the banner (also installed as devbase-env-info)
│ └── config/{.zshrc,.czrc} # user shell configuration
└── test/devbase/
├── test.sh # default options
├── scenarios.json # non-default option combinations
├── minimal.sh # every option off
└── node_project.sh # alongside the Node feature
The build-time / create-time split is the thing to get right. install.sh runs as
root while the image builds, before the workspace is mounted — so it can only do
image-level work. Anything that reads the repository (its manifests, its
env-info.conf) must go in devbase-post-create.sh.
Two conventions the shell code follows throughout, both learned from real breakage:
- A missing prerequisite is reported and skipped, never fatal. A container that comes up without pnpm is fixable from a terminal; one that refuses to come up is not.
- zsh does not word-split unquoted parameter expansions.
for x in ${LIST}iterates once over the whole string under zsh while splitting correctly under bash. Route lists through thewords()helper, which uses a command substitution — zsh splits those.
Run the tests:
pnpm features:test # default options + every scenario
pnpm features:test -- --filter minimal # one scenario, while iterating
pnpm features:lint # shellcheck + metadata validation
pnpm lint # the above plus prettier and actionlintAssert on effects, not on log lines, and check a new assertion against a deliberately broken implementation before trusting it. A check that cannot fail is worse than no check: it reports SUCCESS and sends the next person looking somewhere else.
Releases are semantic-release, driven by commit type — nobody edits version in
devcontainer-feature.json by hand. On a push to main,
release.yml works out the next version from the
commits, writes it into the Feature metadata, commits and tags it, and publishes to
ghcr.io in the same run.
| Commit | Result for a consumer pinned to :1 |
|---|---|
fix(devbase): … |
patch — picked up on their next rebuild |
feat(devbase): … |
minor — picked up on their next rebuild |
feat(devbase): … + BREAKING CHANGE: |
major — not picked up until they change their pin |
ci / docs / chore / test / … |
nothing published |
Release and publish are deliberately one job: semantic-release commits the version bump
using GITHUB_TOKEN, and pushes made with that token do not trigger workflows — so a
separate publish workflow listening on push would never fire for exactly the commit
that matters.
Publishing is idempotent (a version already in the registry is skipped, not overwritten),
which is why the publish step needs no guard, and why
publish-features.yml exists as a manual
escape hatch for the first publish and for re-publishing.
The first publish needs the package made public — ghcr.io packages default to
private, and a private Feature fails every consumer's build with a 401. Set it under
this repository's Packages → the devbase package → Package settings → Change
visibility.
A consumer with no devcontainer-lock.json needs nothing. Every rebuild re-resolves
devbase:1 to the newest 1.x, so a release reaches them by rebuilding. That is the
propagation story the copied-files approach never had — and for most consumers it is the
whole story.
A consumer that commits a lock has to move the pin deliberately, and neither of the two mechanisms you would expect does it for them:
devcontainer upgradeonly fills in missing entries. An entry that still satisfies its reference is re-emitted unchanged, and1.2.0satisfies:1just as well as1.4.0does, so the command is a no-op for exactly the case you want it for.- Dependabot's
devcontainersecosystem bumps version references indevcontainer.json. A frame that references the moving tagdevbase:1has no reference to bump, so no PR is ever raised. This repository has that ecosystem configured and has never received a devcontainers PR, while receiving them forgithub-actionsandnpm— the updater works, it simply has nothing to say about:1.
The result is a lock that silently stays put: this repository's own pinned 1.2.0 through
four releases. To move it, drop the entry and re-resolve:
jq 'del(.features["ghcr.io/centralnicgroup-opensource/rtldev-middleware-devcontainer-features/devbase:1"])' \
.devcontainer/devcontainer-lock.json > /tmp/lock && mv /tmp/lock .devcontainer/devcontainer-lock.json
npx devcontainer upgrade --workspace-folder .
npx prettier --write .devcontainer/devcontainer-lock.jsonDeleting the whole lock instead re-resolves every feature, which is fine but a wider diff.
The prettier line is not optional: the CLI writes the lock with no trailing newline and
pnpm lint rejects it.
If you want Dependabot to do this for you, reference a precise version —
devbase:1.4.0 rather than devbase:1 — and accept a PR per release instead of a moving
major. That is a real trade: the moving tag is what makes a patch reach every repository
without ceremony.
401 Unauthorized pulling the Feature. The ghcr.io package is still private —
see Publishing.
The banner does not appear on attach. It runs from postAttachCommand; check the
Dev Containers output panel. Run devbase-env-info by hand to separate "the banner is
broken" from "the hook did not fire". A syntax error in .devcontainer/env-info.conf is
the usual cause, and the banner deliberately exits 0 regardless so it can never block an
attach.
The prompt is plain, with no git segment. .zshrc is only fully active for an
interactive, non-CI shell, and the theme needs Oh My Zsh in the image (the
mcr.microsoft.com/devcontainers/base images have it). A base image without it still
gets a working shell, just unthemed.
Shell history did not survive a rebuild. historyPersistence needs the host home
bind-mounted at /WSL_USER; check the frame's mounts (or the compose service's
volumes). The step is skipped silently when the mount is absent, because that is a
legitimate configuration. A host with no ~/.zsh_history yet is not that case — the
file is created and linked, so persistence starts on the first create rather than waiting
for a file nothing would ever write.
/WSL_USER is empty in post-create. The mount exists but its source path resolved to
nothing, and Docker created the missing source as an empty directory. On Windows hosts the
usual cause is the ${localEnv:HOME}${localEnv:USERPROFILE} idiom used to name the host
home: it relies on exactly one of the two being set, and concatenates them into a
nonexistent path when both are. Fix the source= in the frame's mounts — the Feature
reports this rather than seeding a history file into a directory the host never sees.
pnpm: command not found in post-create. No Node toolchain in the container. Add
ghcr.io/devcontainers/features/node:2 — installsAfter then guarantees it is installed
before the Feature's post-create runs.
A change to the Feature had no effect. Either version was not bumped (a duplicate
version is skipped at publish), or the consumer has not rebuilt. Rebuild without cache to
be sure: Dev Containers: Rebuild Container Without Cache.
Personal shell customisations disappeared. Expected — the Feature owns ~/.zshrc and
overwrites it on rebuild, which is what stops the prompt drifting per repository. Put them
in ~/.zshrc.local.
- rtldev-middleware-template — the template repository new projects are created from; it ships the devcontainer frames that consume this Feature.
- rtldev-middleware-shareable-workflows — the reusable GitHub Actions workflows those repositories delegate CI to.
- Kai Schwarz — KaiSchwarz-cnic
- Asif Nawaz — AsifNawaz-cnic
MIT — see LICENSE.