Using Dev Container Features Instead of Dockerfiles
The dev container's Dockerfile has grown to 140 lines of curl | bash installers, architecture checks and apt-get pins copied between repositories; it fails on Apple Silicon with cannot execute binary file: Exec format error because one installer downloads an x86_64 tarball, and every repository's copy has drifted slightly. Dev Container Features are self-contained, versioned install units — ghcr.io/devcontainers/features/node:1, .../terraform:1, .../docker-outside-of-docker:1 — that handle architecture, users and PATH setup for you. This page moves a custom Dockerfile to Features, pins them, and writes one small Feature for an internal tool, as part of dev container configuration standards.
The point is less about brevity than ownership: a Feature is maintained once and reused everywhere, instead of the same installer being maintained badly in every repository.
Diagnostic
Look at what the Dockerfile installs and how, and check for architecture assumptions:
#!/usr/bin/env bash
set -euo pipefail
wc -l .devcontainer/Dockerfile
grep -nE 'curl .*\| *(ba)?sh|wget .*\| *(ba)?sh' .devcontainer/Dockerfile | head -5
grep -nE 'x86_64|amd64|linux-x64' .devcontainer/Dockerfile | head -5
jq '.features // {} | keys' .devcontainer/devcontainer.json
devcontainer build --workspace-folder . --platform linux/arm64 2>&1 | grep -iE 'error|exec format' | head -3 || true
Expected bad output:
142 .devcontainer/Dockerfile
18:RUN curl -fsSL https://get.pulumi.com | sh
27:RUN curl -fsSL https://releases.hashicorp.com/terraform/1.9.5/terraform_1.9.5_linux_amd64.zip -o /tmp/tf.zip
27:RUN curl -fsSL https://releases.hashicorp.com/terraform/1.9.5/terraform_1.9.5_linux_amd64.zip -o /tmp/tf.zip
[]
#12 ERROR: process "/bin/sh -c /usr/local/bin/terraform version" did not complete successfully: exit code: 126
A hard-coded amd64 download breaks arm64 builds, unpinned curl | sh installers make builds non-reproducible, and no Features are used.
Root cause
A dev container Dockerfile accumulates installers because each new tool is added the fastest way available: a vendor script, a pinned download URL, a package from the distribution. Each installer has to solve the same problems — choosing the right architecture, installing for the non-root dev user, adding to PATH, cleaning up caches — and each solves them slightly differently, or not at all. Copying the file to other repositories multiplies the fixes needed when an installer changes. Features package that logic once: each Feature is an install.sh plus metadata published as an OCI artifact, it receives options such as version, detects architecture, and knows about remoteUser. The dev container CLI and every compatible tool (VS Code, Codespaces, DevPod) install Features in a defined order on top of a base image, so the Dockerfile can shrink to nothing.
The cost of the hand-written approach grows with every repository that copies it. When HashiCorp changed its download URLs, or when Node moved to a new signing key, each copy of the Dockerfile needed the same fix, and the copies nobody touched kept building from a cached layer until someone cleared the cache and discovered they were broken. With Features, that fix happens once upstream; repositories pick it up through a lockfile update that CI verifies. For organisations with dozens of repositories, that difference alone justifies the migration.
Features also make dev containers easier to read. A reviewer can see from ten lines of JSON exactly which tools and versions a repository expects, which is hard to extract from a long Dockerfile interleaving downloads, checksums and path manipulation. That readability matters for onboarding, because the dev container definition doubles as documentation of the toolchain.
Resolution
- Start from a maintained base image and move each installer to a Feature with a pinned version:
{
"name": "shop",
"image": "mcr.microsoft.com/devcontainers/base:1.2.3-bookworm",
"features": {
"ghcr.io/devcontainers/features/node:1.6.1": { "version": "20.17.0" },
"ghcr.io/devcontainers/features/python:1.6.3": { "version": "3.12.5" },
"ghcr.io/devcontainers/features/terraform:1.3.8": { "version": "1.9.5", "tflint": "0.53.0" },
"ghcr.io/devcontainers/features/docker-outside-of-docker:1.5.0": {},
"ghcr.io/devcontainers-extra/features/pulumi:1.0.2": { "version": "3.130.0" }
},
"overrideFeatureInstallOrder": [
"ghcr.io/devcontainers/features/python",
"ghcr.io/devcontainers/features/node"
],
"remoteUser": "vscode"
}
The Feature reference version (:1.6.1) pins the installer; the version option pins the tool it installs. Both matter.
- Generate a lockfile so Feature versions resolve identically everywhere:
#!/usr/bin/env bash
set -euo pipefail
npm install -g @devcontainers/[email protected]
devcontainer upgrade --workspace-folder .
git add .devcontainer/devcontainer-lock.json
devcontainer-lock.json records the exact digest of each Feature, like a package lockfile.
- Write a small local Feature for internal tools that no public Feature covers:
#!/usr/bin/env bash
set -euo pipefail
mkdir -p .devcontainer/features/acme-cli
cat > .devcontainer/features/acme-cli/devcontainer-feature.json <<'EOF'
{ "id": "acme-cli", "version": "1.0.0", "name": "acme internal CLI",
"options": { "version": { "type": "string", "default": "1.8.2" } } }
EOF
cat > .devcontainer/features/acme-cli/install.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
arch="$(uname -m)"; case "$arch" in x86_64) arch=amd64 ;; aarch64) arch=arm64 ;; esac
curl -fsSL "https://downloads.acme.dev/cli/${VERSION}/acme-linux-${arch}" -o /usr/local/bin/acme
chmod +x /usr/local/bin/acme
EOF
chmod +x .devcontainer/features/acme-cli/install.sh
Reference it as "./features/acme-cli": {"version": "1.8.2"} in devcontainer.json. Options become upper-case environment variables (VERSION) inside install.sh.
- Delete the Dockerfile once the Features build cleanly on both architectures:
#!/usr/bin/env bash
set -euo pipefail
for p in linux/amd64 linux/arm64; do
devcontainer build --workspace-folder . --platform "$p" --image-name "shop-dev:$(echo "$p" | tr / -)"
done
git rm .devcontainer/Dockerfile
Expected output
$ devcontainer build --workspace-folder . --platform linux/arm64 --image-name shop-dev:arm64
[+] Building 94.1s (14/14) FINISHED
=> [dev_containers_target_stage 5/9] RUN ./install.sh (terraform 1.9.5, arm64)
$ devcontainer exec --workspace-folder . bash -lc 'node -v; python3 --version; terraform version | head -1; acme --version'
v20.17.0
Python 3.12.5
Terraform v1.9.5
acme 1.8.2
The image builds on arm64 and amd64 from the same configuration, with every tool at its pinned version and no Dockerfile to maintain.
Rebuilds also get faster in practice. Features install in separate layers keyed by the Feature and its options, so changing one Feature's version rebuilds only from that layer onward, and the prebuilt image published by CI can be reused by every repository that uses the same base and Features.
Prevention
Pin Feature references to full versions (
:1.6.1, not:1) and commitdevcontainer-lock.json; let Renovate or Dependabot propose upgrades.Build the dev container for both architectures in CI with
devcontainers/ci, so an amd64-only installer is caught before an M-series user hits it.Publish shared internal Features from a dedicated repository to a registry (
ghcr.io/acme/devcontainer-features/acme-cli:1) once more than one repository uses them, instead of copying the local folder.
Platform caveats
Apple Silicon (ARM64): Features from the official collection support arm64; community Features vary. Check a Feature's documentation or build with
--platform linux/arm64before adopting it.
Codespaces: Features are installed during prebuilds, so the time they add is paid once per prebuild rather than per codespace; see prebuilding devcontainer images.
Corporate proxies: Features download from GitHub and vendor sites at build time; pass proxy variables through
build.argsor prebuild the image inside the network.
Rollback
Restore the Dockerfile and remove the Features block; both approaches can coexist during migration, since Features install on top of a Dockerfile-built image too:
#!/usr/bin/env bash
set -euo pipefail
git checkout HEAD~1 -- .devcontainer/Dockerfile .devcontainer/devcontainer.json
devcontainer build --workspace-folder .
Frequently Asked Questions
Can I use Features and a Dockerfile together?
Yes. Features install on top of whatever image the Dockerfile produces. Keep the Dockerfile for things Features cannot express, such as system-level configuration, and use Features for tool installation.
What is the difference between the Feature version and the tool version?
The reference tag (node:1.6.1) is the version of the installer script; the version option is the version of Node it installs. Pin both for reproducible builds.
How do I control install order?
Features declare dependencies and install in a computed order. When a Feature needs another installed first, use overrideFeatureInstallOrder to make the order explicit.
Do Features work outside VS Code?
Yes. The dev container CLI, Codespaces, DevPod and other tools implementing the specification install Features the same way.