The Devcontainer Spec, Explained
1. What devcontainer.json actually is
The Development Container Specification is an open spec at containers.dev that defines two things: the shape of a devcontainer.json file, and a lifecycle that a conforming tool runs when it brings the container up. That’s almost the entire surface area. “Reopen in Container” is one client implementation of that spec, and the same configuration drives the Dev Container CLI, GitHub Codespaces, and JetBrains Gateway without changes.
The file tells the tool how to build the container, where to mount your source, what ports to forward, and what to run inside the container after it starts. It’s a strict superset of JSON: comments and trailing commas are allowed (the jsonc dialect), so you can annotate the file without breaking parsers.
The minimum viable example is three lines:
{
"image": "mcr.microsoft.com/devcontainers/javascript-node:20"
}That’s a valid devcontainer. A conforming tool will pull the image, mount your workspace at /workspaces/<repo-name>, and drop you into a shell.
1.1. Where it lives and what reads it
The file lives at one of two paths, and the resolution order matters:
.devcontainer/devcontainer.jsonis the canonical location..devcontainer.jsonat the repo root is accepted as a fallback for single-config repos.
A repo can also carry multiple named configurations under .devcontainer/<name>/devcontainer.json. The CLI and editors discover all of them and let the user pick. This is the right pattern when one repo needs distinct environments (a frontend container and a backend container, say) rather than one container with everything installed.
Tools that read the file include the Dev Container CLI (@devcontainers/cli), VS Code’s Dev Containers extension, GitHub Codespaces, JetBrains Gateway (via the JetBrains Dev Containers plugin), DevPod, and a growing list of others. They all parse the same JSON and run the same lifecycle.
1.2. The three image shapes: image, Dockerfile, dockerComposeFile
You can describe the container three ways, and the choice has consequences.
image points at a pre-built image by registry reference. Fastest startup, no build step. Use this when a published image already has what you need. The mcr.microsoft.com/devcontainers/* family is well-maintained and a good starting point.
build.dockerfile points at a Dockerfile in the repo. The tool builds the image on first use and caches it. Use this when you need anything beyond what a published image gives you and want full control over the image layers.
dockerComposeFile points at a Compose file, and you name which service is the “dev” service. Use this only when you genuinely need multiple long-running services (a database, a message broker, the app), and you want them orchestrated together.
Compose support is the one I’d be careful with. It looks convenient (“I already have a Compose file, just point at it”), but it complicates the lifecycle: the dev tool owns one service’s lifecycle but not the others, feature installation runs inside the named dev service rather than against the Compose graph, and a lot of behavior ends up living in Compose’s surface rather than the spec’s. For a single-container dev environment that happens to talk to a database, a plain Dockerfile plus a forwardPorts entry against an externally-running database is usually less painful. Reach for Compose when the app’s runtime topology needs to match the dev environment, not just to reuse a YAML file you already had.
1.3. Mounts, ports, env, user: the unsurprising bits
The fields that map to Docker primitives behave the way you’d guess:
{
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
"workspaceFolder": "/workspaces/my-app",
"mounts": [
"source=${localEnv:HOME}/.aws,target=/home/vscode/.aws,type=bind,readonly"
],
"forwardPorts": [3000, 5432],
"portsAttributes": {
"3000": { "label": "web", "onAutoForward": "openBrowser" }
},
"containerEnv": { "TZ": "Europe/Istanbul" },
"remoteUser": "vscode"
}Two distinctions worth pinning down on first read.
containerEnv versus remoteEnv: containerEnv is set on the container at creation and is visible to every process including PID 1. remoteEnv is set on the connection from the editor or CLI into the container; it reaches your shell and editor processes but not a service started by postStart. Reach for remoteEnv for things like GIT_EDITOR that only matter for interactive use.
containerUser versus remoteUser: containerUser is who PID 1 runs as. remoteUser is who you are when you attach. Most base images set up a non-root vscode user, and you want remoteUser: "vscode" so your files aren’t owned by root.
Variable substitution is supported in most string-valued fields: ${localEnv:VAR}, ${containerEnv:VAR}, ${localWorkspaceFolder}, ${containerWorkspaceFolder}. This is how you bind-mount your local SSH keys or AWS credentials without hardcoding paths.
1.4. Editor-specific bits live under customizations
The spec is deliberately editor-agnostic, which means it has nothing to say about which VS Code extensions to install or what JetBrains plugin to enable. Those live under a namespaced customizations key:
{
"image": "mcr.microsoft.com/devcontainers/javascript-node:20",
"customizations": {
"vscode": {
"extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"],
"settings": { "editor.formatOnSave": true }
},
"jetbrains": {
"backend": "WebStorm"
}
}
}Tools ignore the customizations keys they don’t understand. This is the escape hatch that keeps the core spec portable. A JetBrains user opening a repo configured for VS Code gets the same container, just without the VS Code extensions installed inside it. The spec’s job ends at “the container is up and your source is mounted”; editor preferences are out of its scope, and rightly so.
2. The five-hook lifecycle
The lifecycle is the part of the spec that does the most work, and it’s the easiest part to misconfigure. There are five hooks. They fire in a defined order, with defined inputs, and each one exists because the others can’t do its job.
2.1. Why there are five hooks and not one
The naive question is: why not one postCreate hook that runs everything? Three reasons.
First, some setup is expensive and should run only when the image content changes, such as installing system packages or building a large native dependency. Other setup is cheap and should run every time the container starts: starting a background service, refreshing a credential. Collapsing them into one hook means you either pay the expensive cost every boot, or you skip the cheap step when it’s needed.
Second, some setup depends on the workspace being mounted (npm install), and some doesn’t (installing system packages can happen before the mount). Splitting these lets the tool prebuild images in CI without needing the workspace.
Third, some setup needs to run before the editor attaches, so the editor sees a usable workspace, and some needs to run after, so it can talk to the editor’s port forwarding. One hook can’t be in both places.
Hence five.
2.2. The five hooks, in order
The hooks fire in this order on a fresh container creation:
On subsequent starts of the same container (you stopped it and started it again), only postStartCommand and postAttachCommand run. onCreate, updateContent, and postCreate are creation-time only by default.
What each one is for:
onCreateCommand runs once, immediately after the container is created, before any source-dependent step. The right place for installing system packages you couldn’t bake into the image, system-level configuration, anything that needs apt, apk, or dnf and root.
updateContentCommand runs after onCreate and after the workspace is available. The right place for pulling submodules, fetching git LFS objects, refreshing generated content that lives in the workspace. The name is a hint: it’s about content updates, not arbitrary setup. Codespaces specifically re-runs this hook on prebuild updates without rebuilding the container.
postCreateCommand runs after the workspace is mounted, once, on creation. The right place for workspace-dependent installs that produce artifacts inside the workspace: npm install, bundle install, pip install -r requirements.txt. The output lands in node_modules/, vendor/, .venv/ inside your mounted source.
postStartCommand runs every time the container starts, including the first time. The right place for starting background services that aren’t PID 1, refreshing short-lived credentials, anything that has to happen on every boot.
postAttachCommand runs every time a client attaches, including the first time and on reconnect. The right place for printing a welcome message, opening a starter file, displaying environment info. It’s the latest hook in the chain and the only one that knows a human is now watching.
Each one accepts a string, an array (treated as argv, not shell-parsed), or an object mapping arbitrary names to commands that run in parallel:
{
"postCreateCommand": {
"deps": "npm ci",
"db-schema": "npm run db:generate"
}
}The object form is how you parallelize independent setup. The string and array forms run sequentially.
2.3. postCreateCommand is overused
postCreateCommand is the default people reach for, and it’s often the wrong choice.
The trap: postCreate runs once, on creation. If you put npm install in there, the first container creation installs your deps. The second time you start that container, your deps are stale relative to whatever you pulled from git in the meantime, and nothing fixes them. You end up running npm install manually anyway.
Two better placements depending on what you actually want.
If you want fresh deps on every start, put it in postStartCommand. Pay the cost on every boot in exchange for never being stale. Cheap when caching works (npm ci against a warm node_modules is fast).
If you want a workspace prepared once and updated explicitly, leave postCreateCommand for the initial install but pair it with updateContentCommand for the refresh path. Codespaces will run updateContent during prebuild updates; the CLI re-runs it on devcontainer up --remove-existing-container.
postAttachCommand gets reached for too often as a “run this every time I open the project” hook. It works, but it runs on every attach including transient reconnects, which means it fires more than you expect. Reserve it for things that genuinely need a human watching: printing the dev URL, opening the README, that kind of thing. Services belong in postStartCommand.
The rule that’s worked for me: write down what the command does and when it needs to run, then pick the hook. Don’t start from the hook.
3. Features: composable install units
Features are the spec’s answer to “I want Node 20, the AWS CLI, GitHub CLI, and Terraform, without writing my own Dockerfile.” They’re composable units of install logic, distributed as OCI artifacts, that the dev tool layers on top of your base image.
3.1. The composition problem they solve
Before features, the practical pattern was: copy an example Dockerfile, paste in install scripts for everything you needed, and maintain that Dockerfile yourself. Every new tool meant another RUN curl | bash, version-pinned by hand, with no shared install scripts across projects.
Features replace those install scripts with named, versioned, parameterized units:
{
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
"features": {
"ghcr.io/devcontainers/features/node:1": { "version": "20" },
"ghcr.io/devcontainers/features/aws-cli:1": {},
"ghcr.io/devcontainers/features/terraform:1": { "version": "1.7" }
}
}Each key is an OCI (Open Container Initiative) reference; the value is a JSON object of options that the feature exposes. The dev tool resolves the references, fetches the feature artifacts, and runs their install scripts inside the building image.
The win isn’t that features do anything you couldn’t do yourself. It’s that you stop copy-pasting the same apt-get install lines across every repo, you get version pinning for free, and the set of community-maintained features that ship under ghcr.io/devcontainers tend to handle the cross-distro details (apt on Debian and Ubuntu, apk on Alpine, dnf on Fedora) better than a one-off script you wrote in an afternoon.
3.2. How a feature plugs in
A feature is, mechanically, an OCI artifact containing two things:
devcontainer-feature.jsoncarries metadata: ID, name, options the feature exposes, and (importantly)installsAfterto declare dependencies on other features.install.shis a shell script that the dev tool runs inside the building image with root privileges. The option values are passed as uppercased environment variables:version: "20"arrives as$VERSION.
The tool resolves the feature graph, orders the installs while respecting installsAfter, and runs each install.sh in sequence. If you want to write your own, the devcontainers/feature-template repo is the starting point. Consider whether what you need is already published before adding to the maintenance burden.
3.3. Order, dependencies, and the sharp edges
Features compose, but not cleanly. Three sharp edges to know about.
Install order is not lexical. The order is determined by installsAfter declarations and a topological sort. If two features both want to modify /etc/profile.d/, the one that runs second wins. Read the source if you care about the final state.
Features run inside the image build, not at runtime. That’s mostly what you want: the installed tooling is baked into the image and there’s no per-start cost. But it also means features can’t reach the mounted workspace. If you need something that depends on workspace content (a tool that reads package.json to decide what to install), it doesn’t belong in a feature.
The feature ecosystem’s quality varies. ghcr.io/devcontainers/features/* is the official set, maintained by the spec’s authors, and it’s solid. Third-party features range from excellent to abandoned. Check the last commit date, the issue tracker, and whether the install script actually handles the distros you care about before adopting one.
When features go wrong, they tend to go wrong silently. The install script fails, the build continues, you get a container without the tool you asked for. Set --log-level trace on the CLI when debugging.
4. The Dev Container CLI
The same JSON, with no editor required. The Dev Container CLI (@devcontainers/cli, distributed via npm) is the reference implementation of the spec. It’s a Node package that builds the image, runs the lifecycle, and gives you a shell inside the container.
The three commands you’ll actually use:
# Build the image and start the container, running the full lifecycle.
devcontainer up --workspace-folder .
# Run a command inside the running container.
devcontainer exec --workspace-folder . bash
# Build the image without starting a container (useful for CI prebuilds).
devcontainer build --workspace-folder .up is idempotent in the sense that it’ll reuse an existing container; pass --remove-existing-container to force a fresh creation, which re-runs onCreate, updateContent, and postCreate.
The reason the CLI matters even if you live in VS Code is CI parity. Your CI can run the same devcontainer up && devcontainer exec ./script that the editor runs locally, against the same JSON, with the same features installed. You stop maintaining a separate “CI Dockerfile” that drifts from the dev environment. Same for headless servers, automation that runs jobs against a defined environment, and pre-warming images for faster startup.
It’s also how you find out whether a problem is in your config or in your editor. If devcontainer up from the CLI reproduces the bug, the editor isn’t the variable.
5. Who else implements the spec
The portability claim is real, and the easiest way to see it is to list the implementations:
- GitHub Codespaces runs the same
devcontainer.jsonin a cloud-hosted VM. Codespaces extends the spec with its own prebuild system (hence theupdateContentCommanddesign) but the core JSON is the same file. - JetBrains Gateway, via the JetBrains Dev Containers plugin, reads the same JSON, runs the same lifecycle, and mounts the IDE backend inside the container so the frontend Gateway client can talk to it.
- VS Code Dev Containers extension is the original client, still the most used.
- DevPod is open-source and runs devcontainers against any backend: local Docker, Kubernetes, a VM.
- Coder, Gitpod, and others have varying levels of conformance, but the JSON is portable enough that most “open the repo and get a dev environment” platforms support it now.
The practical consequence: when you write a devcontainer.json, you’re not buying into a VS Code feature. You’re buying into a portable description of a dev environment that several independent tools can consume. That portability is the spec’s strongest argument.
6. Strengths and rough edges
The lifecycle is the win. Splitting setup across onCreate, updateContent, postCreate, postStart, and postAttach is genuinely thought-out. Each hook answers a real question about when the work needs to happen, and the split lets you build fast prebuilds without giving up dynamic setup. Most dev-environment specs collapse this into one or two hooks, and the result is always either too slow or too stale.
Features are powerful and fragile. The composition story is genuinely useful: community-maintained installs for common tooling, version-pinned, distro-aware. But the failure modes are bad. Silent install failures, opaque ordering, and a long tail of low-quality third-party features mean you end up reading install scripts when things go wrong. The official ghcr.io/devcontainers/features/* set is the safe path; venture outside it carefully.
Compose support is awkward. dockerComposeFile exists for a real use case (the app needs to match its prod-like topology in dev) but it complicates everything: the lifecycle owns one service among many, feature installation targets the dev service only, and Compose’s own surface ends up doing work the spec could do more cleanly. Treat Compose support as an escape hatch, not a default.
The spec is under-documented in places. containers.dev has the schema and the lifecycle, but the gotchas (what installsAfter actually does, why postCreateCommand and updateContentCommand differ in subtle ways, how variable substitution interacts with parallel command objects) are spread across GitHub issues the CLI source and blog posts. Expect to read source code when you push on an edge case.
Net assessment: the spec is the right level of abstraction. It’s not a build system, it’s not an orchestrator, it doesn’t try to be a Nix replacement. It’s a JSON file plus a lifecycle, with a working reference CLI and a half-dozen serious implementations. That’s a small surface that does a lot of work, and it’s worth a working knowledge of even if you never plan to use the VS Code button.