rgoussu@goussu: ~/library/platform/developer-environments
~/library/platform/developer-environments cat dev-containers.md

Dev containers

# The development environment as an image — devcontainer.json and its features, the standalone and compose-attached shapes, and what running your editor inside a container does and does not buy.

Conceptsaved 2026-08-20 #platform#tooling#containers#developer-experience#devcontainer#reproducibility

Overview

A dev container is a development environment defined as a container image and a JSON descriptor committed to the repository. The editor connects into the running container: the language server, the build tools, the tests and the terminal all run inside it, while the source stays bind-mounted from the host. The host needs a container runtime and nothing else — no JDK, no Node, no toolchain manager.

The specification (containers.dev) is open and has three main clients: VS Code's Dev Containers extension, the devcontainer CLI, and GitHub Codespaces. That matters — adopting one does not lock the repository to a single editor.

Key points

  • .devcontainer/devcontainer.json is the whole contract. Base image (or Dockerfile, or Compose service), the tools to add, ports to forward, post-create commands, editor settings and extensions.
  • Features are the reusable toolchain units — declarative, versioned installers (ghcr.io/devcontainers/features/java, .../node, …) composed onto a base image. They are what stop every project maintaining its own bespoke Dockerfile just to get a JDK.
  • Two shapes, and the difference is whether the project needs services:
    • Standalone — image-based, built from a base image plus features. Right for libraries, CLIs and anything with no backing services.
    • Compose-attached — the descriptor points at the project's existing Compose file plus a small overlay declaring the workspace service. The workspace then joins the project's Compose network, so the database and the observability stack are reachable by service name and an already-running environment is attached to rather than restarted.
  • To drive the host's containers from inside the workspace, provision docker-outside-of-docker — it exposes the host's Docker socket, so docker compose … from the workspace controls the same daemon. Prefer it to docker-in-docker, which nests a second daemon and is slower and more privileged.
  • It solves what a version manager cannot: system libraries, native build dependencies, OS-level tooling, and the "works on Linux, not on macOS" class of problem. It does not by itself keep your CI pipeline on the same versions — that is a pinning problem, and a dev container that provisions a different JDK than the pipeline is a new way to be inconsistent, not a fix.
  • Install order should not matter. If services arrive after the container definition, the definition should be upgradable to the attached shape in place. The one case where automation must refuse is a definition a human has customised away from the generated shape — rewriting it silently loses their work; naming the manual recipe does not.
  • The costs are real: image build and rebuild time, a container runtime on every machine, file-system performance on non-Linux hosts, and a debugging surface (port forwarding, permissions, mounts) that is genuinely harder than the host equivalent.

Details

What goes in the descriptor

Key Purpose
image / build / dockerComposeFile The three mutually exclusive ways to say what the container is
features Versioned toolchain installers composed onto the base
postCreateCommand Run once after creation — dependency install belongs here
forwardPorts Ports surfaced on the host
customizations Per-editor settings and extensions, committed with the project
remoteUser The in-container user; getting this wrong is the usual cause of permission grief

Where the versions come from

The features in a descriptor name versions, and those versions are a consumer, not a source. The same JDK major appears in the dev container, the CI pipeline, the release image and whatever the project declares as its requirement. If each states it independently they will drift. Resolve all of them from one registry and add a check that fails when they disagree — see Version pinning & currency.

A version that floats by construction — a major-only tag, or a channel like Rust's stable — is the one legitimate exception, and it should be recorded as a deliberate absence so that a surface quietly growing a hardcoded version is as visible as one carrying the wrong value.

Examples

// .devcontainer/devcontainer.json — standalone
{
  "name": "widget",
  "image": "mcr.microsoft.com/devcontainers/base:ubuntu",
  "features": {
    "ghcr.io/devcontainers/features/java:1": { "version": "25", "jdkDistro": "tem" }
  },
  "postCreateCommand": "./gradlew build"
}
// .devcontainer/devcontainer.json — attached to the project's Compose environment
{
  "name": "widget",
  "dockerComposeFile": ["../dev/compose.yaml", "compose.yaml"],
  "service": "workspace",
  "workspaceFolder": "/workspace",
  "features": {
    "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}
  }
}

Related