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.jsonis 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, sodocker 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
- Developer environments — the group, and where this layer sits.
- Version managers — the cheaper layer below; often enough on its own.
- Version pinning & currency — keeping the container's versions honest.
- Kubernetes & containers deep dive — the image and runtime mechanics underneath.
- CI/CD & delivery engineering — the pipeline as another consumer of the same toolchain.