devcontainer.json Property Reference
This is the schema-index for devcontainer.json: a grouped, annotated reference to the properties every other guide on this site touches. It exists because reproducibility problems are so often precedence problems — a value set in two places, or a substitution that resolved to something unexpected — and the cure is knowing exactly which key owns which concern. Use it as the dictionary alongside the specification guide's grammar.
Every property here is validated by the same command: devcontainer read-configuration --workspace-folder .. When in doubt about what a config actually does, resolve it and read the effective result rather than reasoning about the merge in your head. The architecture guide frames how these properties compose; this page names them.
Prerequisites
Target the current v1 metadata schema, keep the Dev Container CLI handy for validation, let your editor apply the JSON schema for inline hints, and — the golden rule — declare each value in exactly one place so there is nothing to disambiguate.
- The current metadata schema (consumed by the CLI and the Dev Containers extension).
devcontainer read-configurationavailable for resolving the merged config.- Editor JSON-schema association for autocomplete and validation.
- A habit of one source of truth per value.
This page is organized to be used as a working reference rather than read front to back. The properties are grouped by concern — identity and base source, Features and lifecycle, mounts and workspace, environment and user, customizations and ports, and variable substitution — so that when you are authoring a configuration you can jump to the group that matches the decision in front of you. Each group pairs a short conceptual explanation with a table of the exact keys, so you get both the why (which property owns this concern, and how it interacts with the others) and the what (the precise key name, type, and purpose). Treat the specification guide as the grammar and this page as the dictionary: the spec tells you how the language is structured, and this reference tells you what each word means.
The golden rule that heads this section deserves emphasis because it prevents the single largest category of property problems. Declaring each value in exactly one place means there is never a precedence question to resolve, never a surprise about which of two declarations won. Where the spec offers more than one property that could set the same thing — an environment variable expressible in containerEnv, remoteEnv, and a Dockerfile ENV, for instance — pick the one whose semantics match your intent and use only it. When a value does end up expressible in two places despite your best efforts (often because a Feature also sets it), fall back to the second habit: resolve the merged configuration and read what actually took effect.
Identity & Base Properties
These establish what the environment is. name labels it. Exactly one base source is required: image references a prebuilt image, build (with dockerfile and args) builds from a Dockerfile, and dockerComposeFile (with service) delegates to Compose. Choosing among them is the first decision of any config.
| Property | Type | Purpose |
|---|---|---|
name | string | Human-readable label for the environment |
image | string | Base image reference — pin with @sha256:… |
build.dockerfile | string | Path to a Dockerfile to build from |
build.args | object | Build args (e.g. TARGETARCH) passed to BuildKit |
dockerComposeFile | string | array | Compose file(s) for a multi-service stack |
service | string | Which Compose service the editor attaches to |
runServices | array | Subset of Compose services to start |
Pin image by digest per registry best practices; choose dockerComposeFile for multi-service setups per Docker Compose integration.
The choice among image, build, and dockerComposeFile is the first and most consequential decision in any configuration, because it determines the entire shape of everything that follows. Use image when a prebuilt image gives you everything you need — it is the simplest option and the fastest to start, since there is no build step. Use build when you need custom OS packages, a specific non-root user, or any Dockerfile-level control the base image does not provide; you gain flexibility at the cost of a build. Use dockerComposeFile when your environment is genuinely multi-service — an app plus a database, cache, or broker — because Compose owns the service topology and the devcontainer merely attaches to one workspace service. The three are mutually exclusive by design: exactly one base source establishes the environment, and mixing them is a schema error rather than a merge.
A subtle but important detail is that these properties admit graceful evolution. A project can start with a single image reference and later graduate to a build block when it needs a custom package, or to dockerComposeFile when it grows a backing service, without rewriting the rest of the configuration — the Features, customizations, and hooks all carry over unchanged. This is a direct benefit of the layered model: the base source is one layer with a narrow interface, so swapping it does not disturb the layers above. Treating the base choice as something you can revisit as the project's needs change, rather than a decision that must be perfect from the start, keeps early configurations simple without painting you into a corner.
Features & Lifecycle Properties
features injects tooling by OCI reference, overrideFeatureInstallOrder fixes install order, and the six lifecycle commands run in sequence. These are the properties that turn a base image into a provisioned environment.
| Property | When it runs | Typical use |
|---|---|---|
features | build time | Declarative tool injection |
overrideFeatureInstallOrder | build time | Force a specific Feature order |
initializeCommand | host, pre-create | Host-side setup (generate .env) |
onCreateCommand | create, no mount | Prebuild-friendly setup |
postCreateCommand | after mount | npm ci, hook install |
postStartCommand | every start | Seed/start services |
postAttachCommand | every attach | Per-session messaging |
Full detail on the graph and ordering is in Feature & lifecycle hook sequencing.
The six lifecycle command properties are best remembered by their relationship to two events — the workspace mount and container restarts — rather than memorized as a flat list. initializeCommand is the odd one out: it runs on the host before the container exists, so it is the only place for host-side preparation. The next three run inside the container at creation: onCreateCommand before the mount, updateContentCommand on content refreshes, and postCreateCommand after the mount. The last two run repeatedly: postStartCommand on every start and postAttachCommand on every client attach. When you are unsure which to use, ask what the command needs to see and how often it should run, and the answer falls out of this structure.
Each of these command properties accepts the same three value forms, which the reference is easy to forget governs all of them uniformly. A string runs through a shell; an array runs in exec form without shell parsing; an object runs its named entries in parallel. This means the parallelization and quoting-avoidance techniques you learn for one hook apply to every hook. The features and overrideFeatureInstallOrder properties, by contrast, take effect at build time before any of these commands run, which is the ordering that makes Features the right home for durable tooling and hooks the right home for source-dependent setup.
Mounts, Volumes & Workspace
These control where your source lives and what persists. workspaceFolder is the path inside the container; workspaceMount overrides how the source is mounted; mounts adds named volumes for caches and persistent data.
| Property | Type | Purpose |
|---|---|---|
workspaceFolder | string | Path inside the container for your source |
workspaceMount | string | Override the default workspace bind mount |
mounts | array | Extra bind/volume mounts (caches, data) |
{
"mounts": [
"source=devcontainer-node-modules,target=/workspace/node_modules,type=volume",
"source=${localWorkspaceFolder}/.cache,target=/home/vscode/.cache,type=bind"
]
}
Named-volume caching is the highest-leverage performance property; the per-language patterns are in the language configurations guide.
The distinction between a bind mount and a named volume is the concept that unlocks the whole mounts property, and the two serve opposite purposes. A bind mount maps a host directory into the container, so edits are visible on both sides in real time — this is how your source workspace is mounted, and it is why files the container writes there appear on your host (and why ownership matters). A named volume is a Docker-managed store with no host-path equivalent; it persists across container rebuilds but is not a window onto your filesystem, which makes it the correct home for caches and other regenerable state you want to survive rebuilds without cluttering your host. Reaching for a bind mount when you want live editing, and a named volume when you want durable-but-managed storage, is the mental model that makes mounts straightforward.
workspaceFolder and workspaceMount deserve a note because they interact with the base-source choice. In an image or build configuration the tooling mounts your source automatically and workspaceFolder names where inside the container it lands. In a dockerComposeFile configuration the mount is defined in the Compose file instead, and workspaceFolder tells the devcontainer which path in the attached service holds your source. Getting this right is what makes the editor open in the correct directory and the language servers resolve paths correctly; a mismatched workspaceFolder presents as an editor that opens in the wrong place or cannot find the project root.
Environment & User
Environment splits between containerEnv (baked, shared) and remoteEnv (per-session, can reference host values). The user keys — remoteUser, containerUser, updateRemoteUserUID — determine process identity and, critically, keep host-mounted files correctly owned.
| Property | Type | Purpose |
|---|---|---|
containerEnv | object | Env baked at the container level |
remoteEnv | object | Env applied per remote session |
remoteUser | string | User the editor/server runs as (required) |
containerUser | string | User the container process runs as |
updateRemoteUserUID | boolean | Align the user's UID/GID with the host |
Always declare remoteUser — it is the property that prevents root-owned files leaking onto the host bind mount.
The containerEnv/remoteEnv split is the environment-variable equivalent of the mount split, and getting it right prevents a class of precedence surprise. containerEnv values are baked into the container at creation and shared by every process — the right home for stable, non-secret configuration such as a cache directory path or a NODE_ENV. remoteEnv values are applied per remote session and can reference host state through ${localEnv:…}, which makes them the correct channel for per-developer values and the only safe channel for secrets, since a secret in containerEnv would be baked into the container while remoteEnv injects it at session time. When the same variable appears in both, the documented precedence decides the winner, but the cleaner discipline is to pick the right channel for each value and declare it once.
The user properties — remoteUser, containerUser, and updateRemoteUserUID — work together to solve the bind-mount ownership problem that catches every team eventually. remoteUser selects which user the editor and its server run as; containerUser selects which user the container process itself runs as; and updateRemoteUserUID aligns the container user's UID and GID with your host user's, so that files the container writes to your bind-mounted workspace are owned by you on the host rather than by root or a mismatched UID. Declaring a non-root remoteUser is both a security posture (least privilege) and an ownership fix, which is why every configuration on this site sets it. Under rootless engines the alignment is handled differently, through the engine's user-namespace mapping, but the goal — host-owned workspace files — is the same.
Customizations & Ports
customizations namespaces tool-specific config (notably customizations.vscode.extensions and .settings), while forwardPorts and portsAttributes control which container ports reach the host and how.
| Property | Type | Purpose |
|---|---|---|
customizations.vscode.extensions | array | Extension IDs installed into the server |
customizations.vscode.settings | object | Workspace settings applied on attach |
forwardPorts | array | Container ports to forward to the host |
portsAttributes | object | Per-port label, protocol, on-auto-forward |
otherPortsAttributes | object | Defaults for ports not listed explicitly |
The extension side is detailed in the VS Code extension deep dive; port security in forwarding and securing ports in devcontainers.
The customizations namespace is deliberately keyed by tool, and understanding why keeps your configuration portable. Editor settings live under customizations.vscode (or another tool's key) rather than at the top level of the config, so that a client which does not understand a given tool — the headless CLI, or a JetBrains IDE — simply ignores that block while still honouring the shared image, Features, and hooks. This is what lets one .devcontainer/ serve a mixed-editor team: the universal parts of the environment live outside the tool namespace, and only the genuinely editor-specific parts live inside it. When you author customizations.vscode, you are declaring the VS Code-specific surface explicitly, which is exactly the boundary that makes the rest of the config editor-agnostic.
The port properties trade off usability against security, and the defaults lean toward convenience, so tightening them is a deliberate act. forwardPorts lists the ports that reach the host; portsAttributes labels each and sets its onAutoForward behaviour (notify, silent, openBrowser, or ignore); and otherPortsAttributes sets the default for ports you did not list. A default-closed posture — an explicit forwardPorts list plus otherPortsAttributes set to ignore everything else — means only the ports you intend are reachable, which is the auditable state you want. Use silent for background services like a database and notify or openBrowser for the web port a developer actually opens, so the forwarding surfaces what matters without noise.
Variable Substitution
Substitution tokens let one config adapt to host and container context without hardcoding. ${localEnv:VAR} reads a host env var, ${containerEnv:VAR} a container one, and ${localWorkspaceFolder} / ${containerWorkspaceFolder} resolve the workspace path on each side.
| Token | Resolves to |
|---|---|
${localEnv:VAR} | Value of VAR in the host environment |
${containerEnv:VAR} | Value of VAR in the container environment |
${localWorkspaceFolder} | Host path of the workspace |
${containerWorkspaceFolder} | Container path of the workspace |
${localWorkspaceFolderBasename} | Basename of the workspace folder |
Substitution is evaluated during the merge, so read-configuration shows the resolved values — the fastest way to confirm a token did what you intended.
Variable substitution is what lets a single configuration adapt to different hosts without branching, which is essential for a config meant to run on many machines and in CI. ${localEnv:VAR} reaches into the host environment — useful for injecting a per-developer secret or a machine-specific value — while ${containerEnv:VAR} reads a value already set inside the container. The workspace-folder tokens resolve the workspace path on each side: ${localWorkspaceFolder} is the host path, ${containerWorkspaceFolder} the container path, and ${localWorkspaceFolderBasename} the folder name, which is handy for naming volumes or containers uniquely per project. Because these resolve at merge time, the same committed config produces host-appropriate values on every machine, which is precisely the portability that makes one .devcontainer/ serve a whole team.
The one failure mode to watch is a token that resolves to nothing or to a literal. If ${localEnv:VAR} appears unresolved, the host variable is unset in the environment the tooling runs in; if a ${…} appears verbatim in the built environment, the token name is wrong or unsupported in that context. Both are caught instantly by resolving the configuration and reading the effective values rather than assuming the substitution worked. This is the same "read the merged result" discipline that governs precedence: the source shows your intent, but only the resolved configuration shows what the substitution actually produced.
Common Pitfalls
Almost every property pitfall is a precedence or substitution surprise. The triage below routes you to the resolved config.
The through-line of every pitfall in this reference is the gap between the configuration you authored and the configuration that runs. A value set in two keys, a Feature that injected an environment variable you also set, a substitution token that resolved to something unexpected, an extension declared at the wrong nesting level — in each case the source file looks reasonable and the effective configuration is not what you assumed. This is why the single most useful habit for working with these properties is to resolve the merged configuration with read-configuration whenever a value surprises you, rather than re-reading the source and reasoning about what should happen. The resolved output is ground truth; the source is only your intent.
Once you adopt that habit, most property problems become five-minute lookups instead of debugging sessions. You see the effective value, compare it to what you intended, and locate the divergence — a duplicate declaration, a Feature contribution, a substitution that did not resolve. The pitfalls table below enumerates the specific cases, but they all reduce to this one practice: trust the resolved configuration over the source, and verify rather than assume.
| Symptom | Root Cause | Remediation |
|---|---|---|
| A value isn't what you set | Declared in two keys with different precedence | Collapse to one source; verify with read-configuration |
| Root-owned files on the host | remoteUser omitted | Declare remoteUser; enable updateRemoteUserUID |
| Extensions ignored | Declared at root, not under customizations.vscode | Move under customizations.vscode.extensions |
A ${…} token appears literally | Wrong token name or unsupported context | Use a valid token; confirm via resolved config |
| Feature installs out of order | Relied on inferred order | Set overrideFeatureInstallOrder |
Conclusion
The schema fixes the key names, the merge order, and the substitution tokens; your discipline supplies the rest — one source per value, pinned references, and a read-configuration check whenever a value surprises you. Treat this page as the index and the resolved configuration as the ground truth, and precedence bugs stop being mysteries.
The properties in this reference are not a flat list of options to memorize but a small set of concerns, each with an owning property or two. Identity and base source decide what the environment is; Features and lifecycle commands decide how it is provisioned; mounts and workspace decide what persists; environment and user decide who runs what with which values; customizations and ports decide the editor and network surface; and substitution decides how the config adapts per host. Hold that map in your head and any authoring decision routes to the right property quickly, while the tables here fill in the exact key and type. The reference rewards being used this way — concern first, key second — far more than being read straight through.
Above all, remember that these properties describe intent and the merged configuration describes reality. Every hard-to-diagnose property problem lives in the gap between the two, and every such problem is closed the same way: resolve the configuration, read what actually took effect, and reconcile it with what you meant. Make that the reflex, keep each value in one place, and pin every reference, and the configuration becomes exactly what a reference should produce — predictable, auditable, and free of surprises.
FAQ
What is the single most important property to get right?
remoteUser. Omitting it means the container runs as root, and any file it writes to your bind-mounted workspace becomes root-owned on the host, breaking Git and local tooling. Declaring remoteUser (with updateRemoteUserUID where UID alignment matters) keeps ownership predictable and is required across every configuration on this site. It is also a security posture, since running as an unprivileged user contains the blast radius if a dependency or script in the repository turns out to be malicious.
How do I know which value actually won when a key appears twice?
Run devcontainer read-configuration --workspace-folder .. It resolves the full merge — base config, Feature-contributed metadata, and variable substitution — and prints the effective configuration. Rather than reason about precedence abstractly, read the resolved result; if it is not what you intended, collapse the value to a single source. This is far more reliable than tracing the merge by hand, because a Feature you use may contribute a value you never wrote, and only the resolved output reveals it.
When should I use containerEnv versus remoteEnv?
Use containerEnv for stable values every process in the container should share, baked at create time. Use remoteEnv for per-session or per-user values, especially those that reference host state via ${localEnv:…}, since remoteEnv is evaluated per remote session and can pull from the local environment. Secrets in particular belong in remoteEnv referencing a value from your host or secret store, never in containerEnv, because a value in containerEnv is baked into the container while remoteEnv injects it at session time without persisting it into an image layer.
How should I choose between image, build, and dockerComposeFile?
Pick the simplest one that meets your need. Use image when a prebuilt image is sufficient — it is the fastest to start because there is no build. Use build when you need Dockerfile-level control, such as custom OS packages or a non-root user the base image lacks. Use dockerComposeFile when your environment is genuinely multi-service, so Compose owns the service topology and the devcontainer attaches to one workspace service. They are mutually exclusive, but you can migrate from one to another later without rewriting the Features, customizations, or hooks, because the base source is a single layer with a narrow interface.
Why are my extensions or settings being ignored?
Almost always because they are declared at the wrong nesting level. Editor extensions and settings must live under customizations.vscode (customizations.vscode.extensions and customizations.vscode.settings), not at the top level of the configuration. The customizations namespace is keyed by tool precisely so that non-VS-Code clients ignore it, which means a misplaced extension list at the root is honoured by nothing. Move the block under customizations.vscode and resolve the config to confirm it now appears in the merged result.
Related
- Understanding the DevContainer Specification — the schema grammar these properties belong to.
- Feature & Lifecycle Hook Sequencing — the
featuresand lifecycle-command properties in depth. - Docker Compose Integration for Multi-Service Apps — the
dockerComposeFile,service, andrunServiceskeys. - VS Code DevContainer Extension Deep Dive — the
customizations.vscodenamespace. - Container Registry Best Practices for Dev Images — pinning the
imageandbuildsources by digest.