Writing a Custom DevContainer Feature

When your team installs the same internal tool in every devcontainer by copy-pasting shell into postCreateCommand, a custom Feature is the fix. This page authors a reusable Feature — an install script plus devcontainer-feature.json metadata — so the tool installs consistently, in the right order, and by a single reference.

The reason this matters is that postCreateCommand runs after the image is already built, on every container start, and it is invisible to the layer cache. Duplicating the same curl | bash snippet across five repositories means five places to bump a version, five subtly different quoting mistakes, and five chances for one project to drift from the others. A Feature moves that logic to build time, where the install becomes part of the image, gets cached as its own layer, and is described by a machine-readable contract that both the CLI and the editor understand. You author it once and consumers pull it in with a single line in their features object, exactly the way they already consume ghcr.io/devcontainers/features/node or docker-in-docker.

Reach for a custom Feature when the install is more than a one-liner, when it takes options that vary per project (a version, a set of plugins, a flag), or when the order in which it runs relative to other tooling actually matters. The mental model is that a Feature is a small, self-contained installer package: install.sh carries the imperative logic, devcontainer-feature.json declares who the Feature is and which knobs it exposes, and the tooling wires the two together by turning each declared option into an environment variable your script reads. Keep that boundary clean and the Feature stays reusable everywhere it is referenced.

Prerequisites

You need a tool install to package and a place to host the Feature.

  • A shell install script for the tool.
  • A devcontainer-feature.json metadata file.
  • A repo/registry to host the Feature.

Feature prerequisitesYou need an install script, metadata, a reference, and a test.install.shthe logicfeature.jsonmetadata + optionsReferencein featuresTestinstall works

These prerequisites are deliberately minimal: a Feature has no build step and no compilation, so all you truly need is the shell logic, the JSON contract, and somewhere for consumers to fetch it from. The hosting target can be as simple as a ./features/mytool directory inside the same repository during development, which is how you iterate before publishing anywhere. Later that same directory can be pushed to GHCR or another OCI registry without changing a byte of the script or the metadata.

The one detail people get wrong at this stage is assuming the install script inherits their interactive shell environment. It does not. The script runs in a fresh, non-login shell during the image build, as root, with none of your dotfiles, no nvm, no PATH additions from your host, and no cached credentials. Anything the script needs — a specific version string, a download URL, a proxy setting — must arrive either through a declared option or through the base image itself. Treat the script as if it were the very first thing that runs on a bare machine, because from its point of view it very nearly is.

Step-by-Step Implementation

  1. Write the install script. It runs during the build as root.
#!/usr/bin/env bash
set -e
echo "Installing mytool ${VERSION}"
curl -fsSL "https://example.com/mytool-${VERSION}" -o /usr/local/bin/mytool
chmod +x /usr/local/bin/mytool

The set -e on the second line is not decoration: without it a failed curl would still let chmod run and the build would report success while shipping a broken or empty binary. set -e makes the first failing command abort the whole script, so a bad download surfaces as a failed build instead of a mysterious runtime error weeks later. The -fsSL flags on curl matter for the same reason — -f turns an HTTP 404 or 500 into a non-zero exit instead of writing an error page to disk, while -sS keeps the log quiet but still prints real errors, and -L follows redirects so a moved release URL does not silently fail. Writing the binary to /usr/local/bin puts it on the default PATH for every user in the container, and reading ${VERSION} from the environment is what lets the metadata drive the install rather than hard-coding a version into the logic.

  1. Describe it with metadata, including options.
{
  "id": "mytool",
  "version": "1.0.0",
  "options": { "version": { "type": "string", "default": "latest" } }
}

The id and version fields identify the Feature and let consumers pin a specific release, while the options block is the contract that turns the script's ${VERSION} into a documented, defaulted knob. Naming the option version is what makes the tooling export $VERSION into the script's environment — the option key is uppercased to form the variable name, so an option called installPlugins would arrive as $INSTALLPLUGINS. Declaring "default": "latest" means a consumer who references the Feature without setting anything still gets a valid install, which prevents the common failure where an unset option expands to an empty string and the download URL collapses to mytool-. Keep the version in this metadata (the Feature's own version) distinct in your head from the version option (the tool's version); they are different numbers that happen to share a word.

  1. Reference the Feature from a devcontainer.
{ "features": { "./features/mytool": { "version": "1.2.0" } }, "remoteUser": "vscode" }

The key inside features is the Feature's location — here a local ./features/mytool path, which is the fastest way to develop against the script and metadata before publishing them to a registry. The object next to it, { "version": "1.2.0" }, is where the consumer sets the options your metadata declared, so this is the tool version that will reach the script as $VERSION and override the latest default. Setting "remoteUser": "vscode" alongside the Feature is a reminder that the install ran as root at build time but the person using the container is not root; if the tool needs to write to a user-owned directory at runtime, that ownership has to be arranged during the install while you still have root, not assumed afterward.

  1. Test that it installs in a fresh build.

Run the build against a clean container — devcontainer build or reopening in a rebuilt container with no cache — so the install script executes from scratch exactly as a teammate would experience it. Testing on a warm cache hides two of the most common Feature defects: a step that only works because a previous manual run left a file behind, and a script that is not idempotent and would double-install on a second pass. A genuinely fresh build, followed by checking that mytool --version reports the pinned version, is the only proof that the Feature is self-contained and that the metadata, the script, and the reference all agree.

Feature anatomyA Feature is an install script plus metadata whose options become script env vars.install.shruns at build, as rootdevcontainer-feature.jsonid, version, optionsOptions -> envpassed to the scriptReferencedin the features object

Common Pitfalls

Feature bugs are non-idempotent installs or missing option handling.

The permission trap is the one that bites hardest when a Feature manages a cache or a shared directory. Because install.sh runs as root, anything it creates — a /opt/mytool tree, a downloaded plugin cache, a config directory under the user's home — is owned by root unless you fix it. The vscode user then hits permission-denied the first time the tool tries to write there at runtime, and the error looks like a bug in the tool rather than in the Feature. The remedy is to chown the directory to the intended remote user (or make it group-writable) inside the script, while you still have root, so ownership is baked into the image instead of patched at every container start.

The topic-specific pitfall worth expanding is non-idempotency. A Feature's install is not guaranteed to run exactly once — a rebuild or a consumer that layers your Feature on top of another can cause install.sh to execute again. If the script appends a line to a shell profile, clones a git repo without checking whether the directory exists, or writes a symlink with ln -s (which fails if the link is already there), the second run either duplicates state or aborts the build. Guard every mutating step: test for the file before writing it, use ln -sf instead of ln -s, pin the downloaded version so the same input always produces the same binary, and prefer an existence check over blind appends. An idempotent script is what lets the Feature compose cleanly with the rest of the build.

Feature triageA triage path from a broken Feature to a reusable, idempotent one.Does re-running the install duplicatestate?YESMake install.sh idempotentAre options read from env?YESMetadata matches the scriptReusable, correct Feature

SymptomRoot CauseRemediation
Feature install not reproducibleNon-idempotent scriptGuard installs; pin the downloaded version
Option ignoredNot read from the env varRead the uppercased option name from env
Installs in the wrong orderNo installsAfterDeclare ordering in the metadata
Works locally, not when publishedRelative asset pathsBundle assets with the Feature

Conclusion

A custom Feature packages a tool install as a reusable, ordered, option-driven unit: an idempotent install.sh plus devcontainer-feature.json. Reference it once and every teammate installs the tool identically — the same discipline the official Features follow.

The strategic payoff is that you have converted a piece of tribal knowledge into a versioned artifact. Pinning the Feature's version in each consumer's features object means an environment is reproducible in the strong sense: the same reference resolves to the same install script, the same options resolve to the same downloaded binary, and the layer cache reuses the result until one of those inputs changes. That is the pin-and-cache pattern applied to installation rather than to the base image — the version pin buys determinism and the build cache buys speed, for logic that used to live only in a postCreateCommand no cache could ever see.

It also scales the way the wider Features ecosystem does. Because your Feature declares its options and its ordering in metadata, it composes with installsAfter alongside the built-in Features exactly as those Features compose with each other. When the tool needs a new flag, you add an option and bump the Feature version; every consumer opts in on their own schedule by changing one number. That upgrade path — one authored change, many independent adopters, all reproducible — is the real reason to author a Feature instead of maintaining five copies of a shell snippet that slowly drift apart.

Feature payoffPackaging install logic as a Feature gives one reference and consistent, ordered installs.A Feature hasinstall.sh (as root)metadata + optionsdeclared orderingSo you getOne referenceConsistent installsOrdered composition

FAQ

When is a custom Feature worth it over a postCreateCommand? When more than one project installs the same tool. A Feature packages the install once, installs it in a deterministic order during the build (cached as a layer), and exposes options — far better than copy-pasting shell into each project's postCreateCommand. The dividing line is roughly ownership and reuse: if the logic is a genuine one-off for a single repository it can stay in postCreateCommand, but the moment a second project needs it, or the install grows options and ordering requirements, the copy-paste approach starts costing more than authoring a Feature would. Remember too that postCreateCommand runs on every start and is never cached, so a heavy install there is slow forever, whereas the same install as a Feature is paid once per build.

How do options reach my install script? The tooling passes each option as an uppercased environment variable to install.sh. An option named version becomes $VERSION. Declare defaults and types in devcontainer-feature.json, and read the env vars in the script. Because the name is uppercased verbatim, a camelCase option like enableTelemetry arrives as $ENABLETELEMETRY, so it is worth choosing option names that survive that transformation cleanly. Always give each option a default in the metadata; a script that reads an unset variable will expand it to an empty string, and that empty value is the source of a surprising number of malformed URLs and skipped install steps.

How do I control install order relative to other Features? Declare installsAfter (or dependsOn) in the Feature metadata, or have consumers set overrideFeatureInstallOrder. This ensures your Feature runs after any it depends on, the same ordering mechanism the built-in Features use. installsAfter is a soft hint that only takes effect when the named Feature is actually present, which makes it the right tool for "if the user also installs Node, run after it" without hard-requiring Node. When the dependency is genuinely mandatory, install it yourself inside install.sh rather than assuming a consumer will add it, because a soft ordering hint does nothing if the other Feature was never referenced.

Where do I put files my Feature needs to copy into the image? Bundle them next to install.sh inside the Feature directory. Everything in that directory ships with the Feature and is available in the working directory when the script runs, so a config/ folder or helper binary can be copied with a relative path. This is why "works locally, fails when published" almost always traces back to an absolute or host-relative path: reference bundled assets relative to the script's own location, not to a path on your machine.

Should the Feature version or the tool version change when I update? Both, but for different reasons. Bump the tool version through the version option in each consumer's features object when you want a newer binary. Bump the Feature's own version in devcontainer-feature.json when you change the script's behavior, add an option, or fix a bug in the install logic — keeping the two independent lets a consumer pin your Feature at a known-good release while still choosing which version of the tool it installs.

How do I debug a Feature that fails only during the build? Add set -x temporarily to install.sh so every command and its expanded variables print to the build log, then rebuild without the cache so the script actually re-runs. Most build-only failures are a missing base dependency, an option that expanded to an empty string, or a network fetch that a -f on curl correctly turned into a hard failure.