devcontainer.json Explained: Dev Containers, Features, and Codespaces
Last updated:
devcontainer.json describes a reproducible development container for a repo — the OS image, the language runtimes, the CLIs, the editor extensions, the forwarded ports, and the lifecycle scripts that run when the container is created or started. It lives at .devcontainer/devcontainer.json (preferred) or .devcontainer.json at the repo root, and it is consumed by the VS Code Dev Containers extension, GitHub Codespaces, JetBrains Gateway, and the standalone devcontainer CLI. The file format is JSONC, so comments and trailing commas are legal. The killer trick is features — installing CLIs like node, python, gh, or terraform as composable add-ons without writing a single Dockerfile RUN line.
Need to validate a devcontainer.json file? Paste it into Jsonic's JSON Validator — JSONC mode tolerates comments and points to syntax errors with line and column numbers.
Three ways to specify the base: image, dockerFile, dockerComposeFile
Every devcontainer.json needs exactly one base source. Pick the lightest option that fits — image for published bases, dockerFile when you need custom build steps, dockerComposeFile when the dev container needs to start alongside other services.
| Field | What it points to | When to use | Build cost |
|---|---|---|---|
image | A pre-built image reference (registry + tag) | A published base already matches your stack | Pull only — fastest |
dockerFile | Path to a Dockerfile in the repo | You need custom RUN/COPY steps | Build on first create |
dockerComposeFile | Path(s) to docker-compose YAML | Dev container starts with sibling services (DB, queue) | Build + orchestrate |
// Option A — image (recommended starting point)
{
"name": "Node 20 dev container",
"image": "mcr.microsoft.com/devcontainers/typescript-node:20"
}
// Option B — dockerFile (custom build steps)
{
"name": "Custom build",
"build": {
"dockerfile": "Dockerfile",
"context": ".."
}
}
// Option C — dockerComposeFile (with sibling services)
{
"name": "Web + Postgres",
"dockerComposeFile": "docker-compose.yml",
"service": "web",
"workspaceFolder": "/workspace"
}For the compose form, service names which compose service is the dev container, and workspaceFolder tells VS Code where the source is mounted. The other services in the compose file (postgres, redis, etc.) start alongside it.
Features: composable installs from ghcr.io/devcontainers/features
A feature is a reusable install script published as an OCI artifact. Each feature exposes options (typically a version field) and the install system handles ordering and idempotency. Features sit on top of any base — image, Dockerfile, or compose — so you can pick a minimal base and layer the exact tools you need.
{
"image": "mcr.microsoft.com/devcontainers/base:ubuntu-22.04",
"features": {
"ghcr.io/devcontainers/features/node:1": { "version": "20" },
"ghcr.io/devcontainers/features/python:1": { "version": "3.12" },
"ghcr.io/devcontainers/features/github-cli:1": {},
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {
"version": "latest",
"enableNonRootDocker": "true"
},
"ghcr.io/devcontainers/features/terraform:1": { "version": "1.7" },
"ghcr.io/devcontainers/features/aws-cli:1": {},
"ghcr.io/devcontainers/features/kubectl-helm-minikube:1": {}
},
"overrideFeatureInstallOrder": [
"ghcr.io/devcontainers/features/node:1",
"ghcr.io/devcontainers/features/python:1"
]
}Pin versions explicitly ("version": "20") rather than "latest" for reproducible builds. The official feature index lives at containers.dev/features and the source for the canonical features is at github.com/devcontainers/features. Community features published to other registries follow the same OCI format.
overrideFeatureInstallOrder forces a specific order when one feature depends on another (for example, install python before a feature that uses pip). Most of the time the default order works.
Lifecycle commands: onCreateCommand, updateContentCommand, postCreateCommand, postStartCommand, postAttachCommand
Five lifecycle hooks run at different points. Picking the right one matters for both correctness and for prebuild performance in Codespaces.
| Hook | When it runs | Frequency | Typical use |
|---|---|---|---|
onCreateCommand | During image build / prebuild, before source is final | Once per build | System packages, OS-level setup |
updateContentCommand | After source content updates (re-runs during prebuilds) | On content change | npm install, bundle install |
postCreateCommand | After container is created, source is mounted | Once per container creation | First-boot setup, generating types |
postStartCommand | Every time the container starts (incl. first) | On every start | Starting daemons, refreshing tokens |
postAttachCommand | Every time a client attaches | On every attach | Welcome messages, opening files |
{
"image": "mcr.microsoft.com/devcontainers/typescript-node:20",
"onCreateCommand": "sudo apt-get update && sudo apt-get install -y ripgrep",
"updateContentCommand": "npm ci",
"postCreateCommand": "npm run setup && npm run db:migrate",
"postStartCommand": "sudo service postgresql start",
"postAttachCommand": "echo 'Welcome — run npm run dev to start.'"
}For Codespaces prebuilds, prefer onCreateCommand and updateContentCommand for any work that can be cached — output is baked into the prebuild image. postCreateCommand runs after the prebuild image is materialised for a specific user, so its work is not cached across Codespaces.
Each hook accepts a string (run via shell), an array (exec without a shell), or an object whose keys run in parallel: {"a": "task-a", "b": "task-b"}.
customizations.vscode: extensions, settings, devPort
The customizations object holds tool-specific settings. The most-used subtree is customizations.vscode, which controls what VS Code does when it attaches to the container.
{
"image": "mcr.microsoft.com/devcontainers/typescript-node:20",
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"bradlc.vscode-tailwindcss",
"ms-azuretools.vscode-docker"
],
"settings": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"typescript.tsdk": "node_modules/typescript/lib",
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}
},
"codespaces": {
"openFiles": ["README.md", "src/index.ts"]
}
}
}extensions takes an array of Marketplace IDs (the publisher.name form shown in the Marketplace URL). They install on first attach and survive container rebuilds. settings takes a JSON object merged into the workspace settings inside the container — useful for forcing a formatter, an interpreter path, or a tab width regardless of contributor preference.
customizations.codespaces holds Codespace-only equivalents: openFiles opens specified files on first launch, repositories clones additional repos beside the main one, and so on.
Forwarding ports and the portsAttributes object
Dev containers forward TCP ports from inside the container to the host (locally) or to a public *.app.github.dev URL (Codespaces). You list ports explicitly with forwardPorts, and you annotate each port's behaviour with portsAttributes.
{
"image": "mcr.microsoft.com/devcontainers/typescript-node:20",
"forwardPorts": [3000, 5432, 6379],
"portsAttributes": {
"3000": {
"label": "Web app",
"onAutoForward": "openBrowser"
},
"5432": {
"label": "Postgres",
"onAutoForward": "silent",
"requireLocalPort": true
},
"6379": {
"label": "Redis",
"onAutoForward": "notify"
}
},
"otherPortsAttributes": {
"onAutoForward": "ignore"
}
}onAutoForward takes one of notify (default — show a toast), openBrowser, openPreview (in-editor browser tab), silent, or ignore. otherPortsAttributes applies to any auto-detected port not in portsAttributes — set it to ignore to suppress the noise from unrelated processes binding ports.
Environment variables: containerEnv, remoteEnv, runArgs
Three keys set environment variables, and the difference matters because they apply at different layers.
| Field | Scope | Available in |
|---|---|---|
containerEnv | Set on the container process itself (PID 1) | All processes — lifecycle scripts, sub-shells, services |
remoteEnv | Set on the remote client's shell connection only | VS Code terminals and editor processes only |
runArgs | Extra docker run flags (incl. -e) | Low-level escape hatch — sets container-level env |
{
"image": "mcr.microsoft.com/devcontainers/typescript-node:20",
"containerEnv": {
"NODE_ENV": "development",
"DATABASE_URL": "postgres://dev:dev@db:5432/app"
},
"remoteEnv": {
"EDITOR": "code --wait",
"GH_TOKEN": "${localEnv:GH_TOKEN}"
},
"runArgs": [
"--init",
"--cap-add=SYS_PTRACE",
"--security-opt", "seccomp=unconfined"
]
}The ${localEnv:VAR} substitution pulls a variable from the host environment so secrets stay on the host machine — useful for passing through a GH_TOKEN or OPENAI_API_KEY without committing it. In Codespaces, prefer the Codespace Secrets UI: secrets configured there are injected as real environment variables automatically.
GitHub Codespaces specifics: prebuilds, machine sizes, codespaces-specific overrides
Codespaces runs the same devcontainer.json spec on GitHub-hosted VMs. Three things only exist on the Codespaces side.
- Prebuilds.Configured in the repo's Codespaces settings (not in the JSON itself), prebuilds build the container image and run
onCreateCommand/updateContentCommandon a schedule or on push. New Codespaces start in seconds because the heavy work is already baked in. - Machine sizes. The
hostRequirementsfield declares a minimum machine size (cpus,memory,storage), which gates which Codespace sizes the user can pick. Local Dev Containers ignore this field. - Codespaces-only overrides. The
customizations.codespacessubtree holds keys that have no local equivalent:openFiles,repositories, and Codespace-only extension lists.
{
"image": "mcr.microsoft.com/devcontainers/typescript-node:20",
"hostRequirements": {
"cpus": 4,
"memory": "8gb",
"storage": "32gb"
},
"customizations": {
"codespaces": {
"openFiles": ["README.md"],
"repositories": {
"my-org/shared-libs": { "permissions": "read-all" }
}
}
}
}Everything else — features, lifecycle commands, customizations.vscode, forwardPorts — works identically in local Dev Containers and Codespaces. The spec is portable on purpose so a contributor can choose their environment without the repo caring.
Key terms
- Dev container
- A development environment running inside a container, described by a
devcontainer.jsonfollowing the open spec at containers.dev. Lets every contributor open the same project in an identical environment. - Feature
- A reusable installer published as an OCI artifact (usually under
ghcr.io/devcontainers/features) that adds tools to a container without modifying its base image. Features are composable and version-pinnable. - Prebuild
- A GitHub Codespaces feature that builds the container image and runs early lifecycle commands ahead of time, so new Codespaces from the repo start in seconds instead of minutes.
- postCreateCommand
- A lifecycle hook that runs exactly once, right after the container is created and the workspace is mounted. The right place for first-boot setup like
npm installor generating types. - Port forwarding
- The mechanism by which a TCP port bound inside the container becomes reachable from the host (locally) or via a public
*.app.github.devURL (Codespaces). Configured viaforwardPortsand annotated viaportsAttributes. - Codespace
- A cloud-hosted dev container managed by GitHub. Boots from the same
devcontainer.jsona local Dev Container uses, with extra cloud-only knobs for prebuilds, machine sizes, and Codespace-only customisations.
Frequently asked questions
What is devcontainer.json used for?
devcontainer.json describes a reproducible development container for a project. It tells tools like the VS Code Dev Containers extension, GitHub Codespaces, JetBrains Gateway, and the standalone devcontainer CLI exactly how to build and configure an isolated environment with the right OS, language runtimes, CLIs, editor extensions, environment variables, forwarded ports, and lifecycle scripts. The benefit is parity: every contributor — and every Codespace — opens the repo and lands in the same shell with the same tools installed, instead of fighting "works on my machine" issues. The file follows the open container.dev spec, so it is portable across any tool that implements the spec. A new contributor goes from git clone to a fully working environment in one click, with no README full of manual setup steps to follow.
Where do I put devcontainer.json — root or .devcontainer/?
The dev containers spec recognises two locations and the preferred one is .devcontainer/devcontainer.json (a subfolder named .devcontainer at the repo root, containing the JSON file). The alternative is .devcontainer.json directly at the repo root (note the leading dot). Use the subfolder form when you also need supporting files — a Dockerfile, a docker-compose.yml override, or feature scripts — because they sit beside the JSON. Use the single-file root form only for the smallest configs that reference a public image and nothing else. Both VS Code Dev Containers and GitHub Codespaces look for the subfolder form first, then fall back to the root form. You can also keep multiple configs (e.g. .devcontainer/python/devcontainer.json and .devcontainer/go/devcontainer.json) and let the user pick when opening the project.
What's the difference between an image and a dockerFile in devcontainer.json?
image points to a pre-built container image pulled from a registry (for example "mcr.microsoft.com/devcontainers/typescript-node:20"). It is the fastest path because nothing builds locally — the image is pulled, features layer on top, and the container starts. dockerFile points to a Dockerfile inside the repo that gets built before the container starts, letting you install OS packages, copy files, or apply customisations that features cannot express. Use image when a published Microsoft/community image already matches your stack; use dockerFile when you need custom build steps. A third option, dockerComposeFile, points to a docker-compose.yml so the dev container starts together with sibling services like postgres or redis. The three are mutually exclusive — set exactly one of them in a given devcontainer.json.
When does postCreateCommand run versus postStartCommand?
postCreateCommand runs exactly once, right after the container is created for the first time, after the image is pulled or built and features are installed. It is the right place for one-off setup that produces files in the container — running npm install, downloading datasets, generating types, building a binary. postStartCommand runs every time the container starts, including the first start and every subsequent rebuild or restart. It suits things that must run on every boot — starting a background daemon, refreshing credentials, or pulling the latest secrets. There is also onCreateCommand (runs once during prebuild, before the user code is mounted in Codespaces), updateContentCommand (runs whenever the source content changes, useful for prebuilds), and postAttachCommand (runs every time a client attaches, e.g. VS Code reconnects). Pick the earliest hook that is correct — onCreateCommand for prebuild-friendly work, postCreateCommand for first-boot setup, postStartCommand for every-boot work.
How do I install Node.js inside a dev container without writing a Dockerfile?
Use a feature. Features are reusable installer scripts published as OCI artifacts under ghcr.io/devcontainers/features, and adding one is a single key in devcontainer.json. To install Node 20 you add "features": {"ghcr.io/devcontainers/features/node:1": {"version": "20"}}. The build system pulls the feature, runs its install script as root inside the container, and Node is available on PATH. The same pattern installs python, github-cli, terraform, docker-outside-of-docker, aws-cli, kubectl, and many more — the official feature index lives at containers.dev/features. Features compose: list as many as you need under the features object and they install in a deterministic order (you can pin the order with overrideFeatureInstallOrder). The killer benefit is no Dockerfile, no apt-get, no PATH hacking — just declare what tools you want and the version, and they appear.
Can devcontainer.json have comments?
Yes — devcontainer.json is parsed as JSONC (JSON with comments), the same dialect used by VS Code settings.json and tsconfig.json. Both single-line comments starting with double slashes and block comments wrapped in slash-star ... star-slash are allowed, and trailing commas are tolerated by most parsers. This is a deliberate spec choice so config authors can document why a particular image is pinned, which feature provides which CLI, or why a port has a special label. Note that the value of a key still has to be JSON-legal — comments live between keys or above keys, never inside a string literal. Strict-JSON tools that consume the file (some CI parsers) may fail on comments; if you need a strict-JSON copy, run a JSONC-to-JSON pass first. See our JSON Comments guide for the full set of approaches.
How are GitHub Codespaces different from local Dev Containers?
Codespaces runs the same devcontainer.json on GitHub-hosted virtual machines instead of on your laptop, so the spec is identical but a few extra features are available. Prebuilds let you bake the container image plus postCreateCommand output ahead of time so new Codespaces start in seconds instead of minutes. Machine sizes (2-core, 4-core, 8-core, 16-core, 32-core) are selectable per-repo and stored under hostRequirements. The codespaces key inside customizations lets you set Codespace-only behaviour (default extensions, openFiles, repositories to clone alongside). Secrets are managed in the GitHub UI and injected as environment variables, not from a local .env. Network ports forwarded by the container are published to a *.app.github.dev URL automatically. Everything else — features, lifecycle commands, customizations.vscode — works identically in both, which is why the spec is portable.
How do I make VS Code install extensions automatically when the container starts?
Set customizations.vscode.extensions to an array of extension IDs and they install automatically the first time a VS Code client connects to the container. The IDs are the same publisher.name format you see in the Marketplace URL (for example "esbenp.prettier-vscode", "dbaeumer.vscode-eslint", "ms-python.python"). You can also set customizations.vscode.settings to a JSON object of editor settings that get applied to the workspace inside the container — useful for forcing a tab width, a default formatter, or a specific Python interpreter path. Both keys live under customizations.vscode (or customizations.codespaces for Codespaces-only equivalents). The extensions install on first attach, not at container build time, so they survive rebuilds without re-running. To force a fresh install you rebuild the container from the VS Code command palette.
Further reading and primary sources
- containers.dev — Dev Container Specification — Authoritative JSON reference for every field in devcontainer.json
- VS Code — Dev Containers docs — How VS Code implements the spec, including the Dev Containers extension UI
- GitHub Codespaces docs — Codespaces-specific behaviour, prebuilds, machine sizes, and secrets
- Dev Container Features index — Browsable list of features published to ghcr.io/devcontainers/features
- devcontainers/cli on GitHub — Standalone devcontainer CLI for building and running dev containers outside VS Code