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.

Custom Dockerfile vs Features Comparison of installing tools with a hand-written dev container Dockerfile against using versioned Features. Custom Dockerfile vs Features custom Dockerfile Features in devcontainer.json curl piped to sh versioned OCI artifacts arch checks by hand arch handled by Feature copied between repos referenced by ID and version user and PATH ad hoc remoteUser aware
Features move installer logic into shared, versioned, multi-arch units.

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

  1. 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.

  1. 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.

  1. 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.

  1. 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
How Features Build a Dev Container Ordered stages from the base image through Feature installation to the finished dev container. How Features Build a Dev Container 1 — pull pinned base image 2 — resolve Features from lockfile 3 — install in order python, then node 4 — apply remoteUser and PATH 5 — container ready for the editor
Features install in dependency order on top of the base; the lockfile fixes their exact versions.

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

  1. Pin Feature references to full versions (:1.6.1, not :1) and commit devcontainer-lock.json; let Renovate or Dependabot propose upgrades.

  2. 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.

  3. 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.

Dev Container Definition Size Bar chart comparing lines of configuration before and after moving to Features. Dev Container Definition Size Dockerfile, before 142 lines devcontainer.json, after 24 lines local Feature 12 lines
Most of the saved lines were architecture handling and PATH setup now owned by Features.

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/arm64 before 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.args or 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.