DevContainer Architecture & Core Tooling

This guide defines the structural boundaries, schema requirements, and execution pipeline that make a containerized development environment reproducible rather than merely convenient. It is written for DevOps engineers, tech leads, and open-source maintainers who need every teammate — and every CI runner — to resolve the same toolchain, byte for byte, on the first attach. The guarantee this architecture delivers is determinism: given a pinned base image, a resolved Feature graph, and idempotent lifecycle hooks, the environment materializes identically on Apple Silicon, x86_64 Linux, and cloud hosts alike.

The design prioritizes immutable infrastructure and declarative configuration. Environment state is never accreted by hand inside a running container; it is declared, version-controlled, and rebuilt. That single discipline eliminates the "works on my machine" class of failure, because there is no my machine — only the specification and the layers it produces. Everything below maps to a dedicated guide, so treat this page as the map and each linked page as the territory.

Why does this matter enough to adopt a whole architecture for it? Because the cost of environment drift is paid continuously and invisibly. It shows up as an afternoon lost onboarding a new hire whose Node version resolves a dependency differently, as a CI failure that no one can reproduce locally, as a native module that works for the backend team and segfaults for the frontend team. None of these are dramatic outages; they are a steady tax on every engineer, and they compound as a team grows and its hardware diversifies. A reproducible environment converts that recurring, unpredictable cost into a one-time, version-controlled definition — you pay the modelling cost once and the environment materializes correctly forever after.

DevContainer build pipelineA base image is composed with Features, processed through lifecycle hooks, then surfaced to the IDE remote layer.Base ImageOCI digest · registryFeaturesordered injectionLifecycle HooksonCreate → postAttachIDE Remote Layerclient / server splitEach stage is deterministic and cache-addressable

Specification & Schema Compliance

Every environment begins as a JSON document validated against the published devcontainer schema. The specification version governing this guide is the current v1 metadata schema consumed by the Dev Container CLI and the VS Code Dev Containers extension. Two invariants are non-negotiable at parse time: exactly one of image or build must establish the base execution context, and remoteUser must be declared explicitly so that host-mounted volume permissions and in-container process ownership stay predictable. Omitting remoteUser is the single most common source of root-owned files leaking onto the host bind mount.

Schema validation runs before any layer is built. The CLI command devcontainer read-configuration --workspace-folder . resolves the effective configuration — merging Features, applying variable substitution, and expanding dockerComposeFile references — and exits non-zero on any structural error. Wiring that command into a pre-commit hook or a CI lint stage means a malformed customizations block or an unresolvable Feature reference is caught at review time, not at 09:00 when a teammate's workspace refuses to hydrate. The complete rules — required keys, precedence, and migration notes between schema revisions — live in Understanding the DevContainer Specification, and the per-property contract is catalogued in the devcontainer.json property reference.

Schema validation gateConfiguration is validated against the spec schema before the container is built; failures block hydration.devcontainer read-configuration parsescleanly?NOSchema error — workspace hydrationblockedimage or build declared + remoteUserset?YESFeature graph resolves, container buildsDeterministic environment attaches

Precedence is where most misconfigurations hide. When a value is expressible in more than one place — say an environment variable set in the Dockerfile, in containerEnv, and again in remoteEnv — the resolution order is fixed by the spec, and guessing it wrong produces environments that pass validation but behave inconsistently. Treat the schema as the contract and the property reference as its annotated commentary; never rely on editor autocomplete alone to tell you which key wins.

Schema compliance also governs how the configuration evolves. Between metadata revisions the spec is deliberately additive — new optional keys appear, and deprecated keys keep working for a transition period rather than breaking existing configs — so migration is rarely a big-bang rewrite. The practical discipline is to re-run read-configuration after any bump of the CLI, the extension, or a Feature, and to diff the resolved output against the previous run. A key that silently changed meaning, a default that shifted, or a Feature that now injects a different environment variable all show up immediately in that diff. This is the same regression-testing instinct you apply to code, applied to the environment definition itself.

There is a second compliance surface that teams routinely miss: the metadata a Feature contributes back into the merged configuration. A Feature can declare its own containerEnv, entrypoints, and customizations, all of which merge into your effective config at resolution time. That means the document you author is not the whole story — the resolved document is. Auditing the merged result rather than the source file is what catches a Feature quietly setting a PATH entry or recommending an extension you did not intend, and it is why read-configuration (which prints the merged view) is the authoritative reference, not the raw JSON in your editor.

Architecture Overview

Think of a devcontainer as a stack of layers, each owning exactly one concern and communicating with its neighbours through a narrow, documented interface. At the bottom sits the container runtime and host — the Docker or Podman engine, the kernel, and the bind/volume mounts that expose your source tree. Above it, the base image contributes the operating system and the core toolchain, pinned to an immutable OCI digest. The Feature layer injects additional tools declaratively, without editing the base. Lifecycle hooks then run ordered, idempotent commands to finish provisioning. Finally the IDE remote server hosts language servers and terminals inside the container, while the IDE client on your host renders the UI.

Responsibility layers of a devcontainerSix stacked layers from the host runtime up to the IDE client, each owning a distinct concern.IDE Clienteditor UI, keybindings, local extensionsIDE Remote Serverlanguage servers, terminals, debug adaptersLifecycle HooksonCreate → updateContent → postCreate → postStart → postAttachFeature Layerdeclarative tool injection over the baseBase Imagepinned OCI digest, OS + core toolchainContainer Runtime + HostDocker/Podman engine, kernel, mounts

The value of drawing hard boundaries between these layers is that a fault is always attributable. A missing compiler is a base-image or Feature concern; a half-installed dependency is a hook concern; a laggy editor is an IDE-remote concern. Because responsibilities do not bleed across layers, remediation is local: you fix the owning layer and rebuild, rather than mutating a live container and hoping the change survives the next rebuild. This is the architectural payoff of separation of concerns, and every downstream guide respects the same boundaries.

The interface between layers is as important as the layers themselves, because that is where reproducibility is either preserved or lost. The base image exposes an OCI digest; the Feature layer consumes it and exposes an install contract; the hooks consume the provisioned filesystem and expose a ready workspace; the IDE server consumes that workspace and exposes a remote-execution endpoint. Each interface is narrow and declared, which is exactly why a change in one layer cannot silently perturb another. When a team debates "should this go in the Dockerfile or a Feature or a hook?", the answer falls out of the interfaces: durable OS state that every rebuild needs belongs below the Feature line; project-specific, source-dependent setup belongs above it in the hooks.

A useful way to internalize the stack is to trace a single request through it. A developer opens the repository; the client asks the engine to build or pull the base image at its pinned digest; the Feature graph resolves and each Feature's install script runs in order, layering tools onto that base; the container starts and the lifecycle hooks fire in sequence against the now-mounted workspace; finally the IDE server is provisioned inside the container and the client attaches to it. Every one of those steps is deterministic and cacheable, which is what lets the same trace replay identically on a laptop, a CI runner, and a cloud host. When something breaks, you walk the same trace in reverse and the failing layer announces itself.

Base Image & Registry Strategy

Environment parity begins with an immutable base. A tag such as ubuntu:22.04 is a moving pointer: the bytes it resolves to change whenever the upstream maintainer republishes. Reproducible environments therefore pin the resolved sha256 digest, not the tag, so that mcr.microsoft.com/devcontainers/base:ubuntu@sha256:… names one and only one image forever. The mechanics — resolving, recording, and refreshing digests on a cadence — are covered in pinning base image digests with sha256, and the broader registry posture in container registry best practices for dev images.

Base-image supply chainAn image flows from authoring through scanning and SBOM generation to a registry, then is pinned by digest.AuthorDockerfile + argsBuildBuildKit multi-stageScan / SBOMTrivy · syftRegistrypush by digestPinsha256 in configReproducibility depends on pinning the resolved digest, never a floating tag

Beyond pinning, a mature base-image strategy treats the image as a supply-chain artifact. Multi-stage builds separate build-time dependencies (compilers, headers, package caches) from the slim runtime layer that developers actually attach to, shrinking both image size and attack surface. A generated SBOM (via syft) records dependency provenance, and a scan (via trivy) fails the build when a known-vulnerable package slips in. Choosing the distro base itself is a deliberate trade-off between size and ecosystem completeness — walked through in choosing between Alpine and Debian base images. Layer ordering matters too: put the rarely-changing system packages early so BuildKit can cache them, and the frequently-changing application dependencies late.

Registry topology becomes a performance concern the moment more than a handful of engineers share an image. A cold pull of a 600 MB development image over a home connection is a real fraction of a minute, and it recurs on every fresh environment. A pull-through cache — a registry mirror that fetches an upstream image once and then serves it locally — turns those repeated cold pulls into near-instant local reads, and a private mirror for images your own team builds removes the round trip entirely. Configure the engine's registry mirrors deliberately rather than leaving every developer to hammer the public registry, and the aggregate time saved across a team is substantial.

Pinning discipline extends downward from the base image to everything it composes. A base pinned by digest but layered with Features referenced by a floating major tag is only partially reproducible: the Feature can change what it installs between two builds even though the base did not move. The rule is therefore uniform — pin the base by @sha256, pin each Feature to at least a major version (ideally a digest), and treat a version bump anywhere in that chain as a reviewed change with a re-scan and a refreshed SBOM. Reproducibility is a property of the whole supply chain, and a single floating reference anywhere in it reintroduces the drift the rest of the discipline works to eliminate.

Feature Composition & Lifecycle Hook Sequencing

Features are the spec's mechanism for adding tooling without forking the base image. Each entry in the features object references a published Feature (for example ghcr.io/devcontainers/features/node:1) that contributes an install script, environment variables, and optional Feature-to-Feature dependencies. Features install in a resolved, deterministic order; when order matters you express it with overrideFeatureInstallOrder rather than hoping alphabetical resolution does the right thing. The full contract — install graph, option merging, and idempotency expectations — is detailed in Feature & lifecycle hook sequencing.

Lifecycle hook sequenceThe five lifecycle hooks fire in a fixed order from container creation through client attachment.1onCreateCommandruns once at create,no bind mount2updateContentCommandcontent refresh,prebuild-friendly3postCreateCommandworkspace mounted,install deps4postStartCommandevery start, seedservices5postAttachCommandeach client attach

Lifecycle hooks finish what Features start, and they fire in a strict sequence. onCreateCommand runs once at creation, before your source is bind-mounted, which makes it the right place for work that must be baked into a prebuild. updateContentCommand runs on content refreshes and is the hook Codespaces prebuilds re-execute. postCreateCommand runs after the workspace is mounted — the correct home for npm ci, poetry install, or go mod download. postStartCommand runs on every start (seed a database, warm a cache), and postAttachCommand runs each time a client attaches. The cardinal rule is idempotency: a hook must produce the same state whether it runs once or ten times, because rebuilds and reattaches will run it repeatedly. Sequencing hooks against mount availability is what prevents the race conditions that corrupt a half-hydrated workspace.

Idempotency is easy to state and easy to violate. The classic failure is a hook that appends a line to a shell profile or symlinks a dotfile without first checking whether the work is already done; after the second rebuild the profile has two copies of the export and the PATH is subtly wrong. The defensive pattern is check-then-act — grep -q … || echo … for appends, ln -sf for links, package managers that reconcile against a lockfile rather than installing blindly. Where the logic is non-trivial, move it into a shell script the hook calls rather than cramming it into an inline JSON string, so it can be tested in isolation and reasoned about like any other code. A hook you cannot run twice safely is a latent corruption waiting for the next rebuild.

The other lever hooks give you is prebuild economics. Because onCreateCommand runs before the source mount and its result is cacheable, it is the ideal stage to bake into a prebuilt image: a GitHub Codespaces prebuild, or a CI-warmed image, executes that stage ahead of time so a developer's create becomes a fast attach. The design goal, then, is to push as much source-independent setup as possible up into onCreateCommand — installing tools, warming global caches — and to keep only genuinely source-dependent work (installing dependencies from this commit's lockfile) in postCreateCommand. Get that division right and the expensive part of environment creation happens once, centrally, instead of once per developer per rebuild.

Orchestration & Network Topology

Single-container environments cover the simple case; real applications need a database, a cache, and often a message broker. Rather than cramming those into one image, the architecture delegates multi-service provisioning to a Compose file referenced from dockerComposeFile, with the devcontainer attaching to a nominated service. This keeps each service independently versioned and scalable, and it lets the same Compose stack run in CI. The end-to-end pattern is documented in Docker Compose integration for multi-service apps.

Compose service topologyThe workspace and backing services share a user-defined bridge network where service names resolve by DNS.appworkspacedbpostgres:16cacheredis:7brokerrabbitmqproxynginxdevnetuser bridge

Networking uses a user-defined bridge network so that container-to-container name resolution works: the app service reaches Postgres at the hostname db, never a hardcoded IP that changes on every recreate. Embedded Docker DNS resolves service names to current container addresses automatically. When resolution or routing misbehaves — a service unreachable, an intermittent getaddrinfo ENOTFOUND, a DNS answer that points at a stale container — the systematic teardown lives in debugging network & DNS issues in containers. Port forwarding is the other half of topology: forwardPorts exposes a container port to the host with an explicit, auditable policy rather than publishing everything by default.

Startup ordering is the subtlety that separates a Compose stack that works on a fast laptop from one that works everywhere. An application that connects to its database the instant its container starts will race the database's initialization and fail intermittently — a bug that reproduces only under load or on slower hosts. The fix is to make readiness explicit: give each backing service a healthcheck, and gate the workspace on depends_on: condition: service_healthy so the environment starts only once its dependencies genuinely accept connections. This replaces the fragile "sleep and hope" pattern with a real synchronization primitive, and it is the single most effective cure for flaky multi-service startups.

Delegation to Compose also pays off well beyond local development, because the same stack definition runs unchanged in CI. A pipeline that builds the devcontainer inherits the identical database, cache, and broker the developer uses, so integration tests execute against real services rather than mocks, and "works on my machine" extends honestly to "works in the pipeline." Keeping the service topology in a Compose file the devcontainer merely references — rather than duplicating it into bespoke CI setup — is what makes that parity free. The devcontainer owns the attach point and the IDE layer; Compose owns the topology; neither reaches into the other's domain.

IDE Integration & Remote Execution

The Dev Containers model is a client/server split. The editor UI — rendering, keybindings, and UI-only extensions — runs on your host. Everything that touches the code runs inside the container: language servers, debug adapters, the integrated terminal, task runners, and file watchers. A thin control channel connects them, and workspace extensions are installed into the container's server, not the host client. This is why an environment feels native yet stays perfectly isolated.

IDE client/server responsibility splitThe editor UI runs on the host while language servers, debuggers and terminals run inside the container.IDE Client (host)Editor UI + renderingUI extensionsKeybindings + themesSource control UIPort-forward tunnel endpointRemote Server (container)Language serversDebug adaptersIntegrated terminal / shellsWorkspace extensionsFile watchers + tasks

Getting the split right means declaring workspace extensions and settings under customizations.vscode, so every teammate attaches to an identical editor surface — the same linters, the same formatter, the same debug configuration. The extension lifecycle, server placement, and settings isolation are covered in the VS Code DevContainer extension deep dive. Where the same configuration should run on a hosted host instead of a local engine, weigh the trade-offs in GitHub Codespaces vs local devcontainers; the split is identical, only the location of the server moves. Workspace trust boundaries must be configured deliberately so that opening a repository never silently executes a postCreateCommand you did not intend to run.

The client/server split has a direct consequence for extension placement that trips up almost everyone once. Extensions come in two kinds: workspace extensions (language servers, linters, debuggers) that must run where the code lives — inside the container — and UI extensions (themes, some source-control interfaces) that run on the host client. Declaring a workspace extension under customizations.vscode.extensions installs it into the container's server, which is why every teammate gets identical code intelligence; declaring a UI-only extension there appears to "do nothing" because it belongs on the client. Understanding which side an extension lives on is the difference between an editor that behaves identically for the whole team and one that mysteriously differs per machine.

Because the split is a property of the specification rather than of any one editor, it is also what makes the environment editor-agnostic. The same .devcontainer/ that a VS Code user opens can be consumed by a JetBrains IDE or driven headlessly by the CLI; the base image, Features, and hooks apply identically, and only the editor-specific layer differs. That portability is worth protecting: keep the shared parts of the configuration free of editor-specific assumptions, and a mixed-editor team shares one reproducible environment instead of maintaining a fork per tool.

Cross-Platform Parity

A team on mixed hardware — Apple Silicon laptops, x86_64 CI runners, ARM cloud instances — needs one configuration that resolves natively everywhere. The lever is build-time architecture injection. BuildKit exposes TARGETARCH and TARGETOS, and a well-written Dockerfile uses them to select architecture-appropriate binaries and wheels instead of hardcoding amd64 downloads that then require slow QEMU emulation on an M-series chip.

Cross-platform build matrixHow TARGETARCH, emulation, native binaries and cache keys differ across three host architectures.Apple Siliconx86_64 LinuxLinux ARM64TARGETARCHarm64amd64arm64EmulationnonenonenoneNative binariesarm64 wheelsamd64 wheelsarm64 wheelsCache keyarm64 digestamd64 digestarm64 digest

The parity matrix above is the mental model: the configuration is constant, while TARGETARCH, the native binary set, and the resolved cache key vary per host. Building images that genuinely run native on each is the subject of multi-architecture builds for ARM & x86, and the hands-on buildx + QEMU procedure is in building multi-arch images with buildx and QEMU. The failure mode to avoid is a single-arch image that works under emulation but installs the wrong native extensions, silently degrading performance and occasionally breaking native modules outright.

The insidious quality of an architecture mismatch is that it rarely fails loudly. An amd64 image running under emulation on an Apple Silicon laptop starts, attaches, and runs your tests — just slowly, and occasionally with a native module that segfaults or a compiled dependency that behaves subtly differently than it does in CI. Because nothing errors outright, the cost hides as a persistent tax on every developer on the "wrong" architecture, and it surfaces as intermittent, hard-to-reproduce bugs. Publishing a genuine multi-architecture manifest — one tag that resolves to a native image per host — removes both the tax and the class of bug, which is why parity is worth the extra build complexity.

Getting parity right is as much a Dockerfile discipline as a build-tooling one. The rule is to never hardcode an architecture string: instead of downloading tool-linux-amd64, branch on $TARGETARCH to fetch tool-linux-${TARGETARCH}, and let BuildKit's --platform flag drive which variant each build produces. Language ecosystems then do the rest — Python wheels, Node native addons, and Go's cross-compilation all key off the target architecture — so a correctly parameterized Dockerfile produces a correct native image for every platform from one set of instructions. The parity you want is emergent from that single discipline, not from maintaining a separate config per architecture.

Canonical Configuration

The reference configuration below shows the three files working together, each owning its layer. The devcontainer.json selects Features and declares hooks and IDE config; the Dockerfile owns OS packages and the non-root user; the Compose file owns the service topology. Note that remoteUser is present — spec compliance and permission hygiene both depend on it.

Configuration file ownershipThe Dockerfile owns OS and toolchain layers; devcontainer.json owns Features, IDE config and hooks.Dockerfile ownsBase OS layersSystem packagesNon-root user creationCompiled toolchainsdevcontainer.json ownsFeature selectionIDE customizationsLifecycle hooksPort + mount policy

Read the three files as answers to three different questions. The Dockerfile answers "what operating system and toolchain does every rebuild need?" — durable, source-independent state, pinned by digest and ordered so cache is preserved. The devcontainer.json answers "how is this environment composed and provisioned?" — which Features to inject, which hooks to run and when, and which editor surface to present. The Compose file answers "what services does the application talk to?" — the database, cache, and broker topology, with health checks that make startup ordering real. No file reaches into another's question, which is precisely why a change to one is safe to reason about in isolation.

Notice the small details that carry the reproducibility guarantee. The base is pinned with @sha256, not a floating tag. TARGETARCH flows from the host through build.args into the Dockerfile so the same file builds native on every architecture. remoteUser is node, a non-root user, so files written to the bind-mounted workspace stay owned by the developer on the host. Dependency installation lives in postCreateCommand (after the mount exists), while service seeding lives in postStartCommand (every start). Each of those choices is deliberate, and together they are what turn a configuration that merely works into one that works identically for everyone.

{
  "name": "Architecture-Compliant Dev Environment",
  "build": { "dockerfile": "Dockerfile", "args": { "TARGETARCH": "${localEnv:TARGETARCH}" } },
  "features": {
    "ghcr.io/devcontainers/features/node:1": { "version": "20" },
    "ghcr.io/devcontainers/features/docker-in-docker:2": {}
  },
  "customizations": {
    "vscode": {
      "extensions": ["dbaeumer.vscode-eslint", "redhat.vscode-yaml"],
      "settings": {
        "terminal.integrated.defaultProfile.linux": "zsh",
        "editor.formatOnSave": true
      }
    }
  },
  "forwardPorts": [3000],
  "postCreateCommand": "npm ci",
  "postStartCommand": "npm run db:seed",
  "remoteUser": "node"
}
FROM --platform=$TARGETPLATFORM mcr.microsoft.com/devcontainers/base:ubuntu@sha256:PINNED AS base
ARG TARGETARCH
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
    build-essential curl git \
    && rm -rf /var/lib/apt/lists/*
# non-root user is created by the base image as 'node'; keep workspace ownership consistent
WORKDIR /workspace
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    volumes:
      - ../..:/workspace:cached
    command: sleep infinity
    networks: [devnet]
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: ${DB_PASSWORD:-devpass}
    volumes:
      - pgdata:/var/lib/postgresql/data
    networks: [devnet]
volumes:
  pgdata:
networks:
  devnet:
    driver: bridge

Common Pitfalls

The table records the failures that most often break reproducibility, each traced to the layer that owns the fix. Use the triage path beside it when a build "works for me but not for them": pinning first, permissions second, hook idempotency third.

What unites every row is that the symptom appears in one place while the cause lives in a specific layer, and the whole value of the layered architecture is that the mapping from symptom to owning layer is deterministic. "Builds differ between machines" is always a pinning failure somewhere in the supply chain; "root-owned files on the host" is always a remoteUser/UID concern; "half-provisioned workspace" is always a hook-ordering or idempotency concern. Because the boundaries are clean, triage is a lookup rather than an investigation — you identify the layer, fix its file, and rebuild, instead of poking at a live container hoping the change persists.

Drift triage pathA triage path for locating non-determinism: pinning, user/permissions, then hook idempotency.Does the same commit rebuildbit-identically?NOFloating tag or unpinned Feature in playIs remoteUser set and volumeschown-clean?YESHooks idempotent and correctly orderedDrift is traceable to one owning layer

SymptomRoot CauseRemediation
Builds differ between machines on the same commitFloating tag (:latest) or an unpinned Feature versionPin the base by @sha256 digest and pin every Feature to a major version
Root-owned files appear on the host mountremoteUser omitted, container runs as rootDeclare remoteUser and align the container user's UID/GID with the host
Workspace hydrates half-provisionedpostCreateCommand ran before the mount was ready, or is non-idempotentMove mount-dependent work to postCreateCommand; make hooks safe to re-run
Native extensions fail on Apple SiliconSingle-arch (amd64) image running under emulationBuild multi-arch and select binaries via TARGETARCH
Service unreachable by nameHardcoded IP instead of the Compose service hostnameUse the service name as the DNS host on a user-defined bridge network

Conclusion

The core insight of this architecture is separation of concerns made literal in files. The Dockerfile owns OS and toolchain dependencies; devcontainer.json owns Features, IDE configuration, and lifecycle hooks; the Compose file owns runtime service topology. When each layer has one owner and one interface, environment drift is not a mystery to be debugged in a live container — it is a diff in a version-controlled file, traceable to exactly one place and fixable by a rebuild. The same discipline extends outward: run the environment without a root daemon via rootless Podman engines, build and test the identical container in your pipeline with CI/CD integration, and harden it with security and secrets management.

If you take one principle from this guide, make it this: never fix an environment by mutating a running container. Every fix belongs in the file that owns the concern, so the fix is captured, reviewed, and replayed automatically on the next rebuild for every teammate. The moment you apt-get install one more package or edit one more config inside a live container, you have created state that exists on exactly one machine and vanishes on the next rebuild — the precise failure this architecture exists to prevent. Treat the container as disposable and the configuration as authoritative, and reproducibility stops being an aspiration and becomes a property you get for free.

Separation of concernsEach configuration file owns one layer, so environment drift is always traceable to a single owner.DockerfileOS + toolchain dependenciesdevcontainer.jsonFeatures, IDE config, lifecycle hooksdocker-compose.ymlruntime service topology

FAQ

Which devcontainer spec version governs this guide, and how do I migrate? These guides target the current v1 metadata schema consumed by the Dev Container CLI and the VS Code Dev Containers extension. Migration between schema revisions is additive in practice: new optional keys appear, and deprecated ones keep working for a release cycle. Validate with devcontainer read-configuration after any bump, and consult the specification guide for the authoritative key list.

What is the precedence between the Dockerfile, devcontainer.json, and a Compose override? Execution follows a fixed hierarchy: the image is built (Dockerfile or image), Features are composed onto it in resolved order, Compose brings up the service topology, and only then do lifecycle hooks run. For overlapping values such as environment variables, the spec's documented precedence decides the winner — never leave it to chance; pin the value in one place.

Why must every configuration declare remoteUser even for a single-developer project? Because bind mounts share a UID namespace with the host. Without an explicit remoteUser, the container runs as root and any file it writes to the mounted workspace becomes root-owned on your host, breaking subsequent Git operations and local tooling. Declaring remoteUser keeps ownership predictable and is a spec-compliance requirement across every snippet in this guide. It is also a security posture: running as an unprivileged user contains the blast radius if a dependency or script in the repository turns out to be malicious.

Where should a piece of setup live — the Dockerfile, a Feature, or a lifecycle hook? Decide by asking two questions. First, is the setup durable and needed by every rebuild regardless of your source? If so it belongs below the Feature line — in the Dockerfile for OS packages and users, or in a Feature for a reusable, ordered tool install. Second, does it depend on your project's source, such as installing dependencies from this commit's lockfile or wiring git hooks into .git? Then it belongs in a lifecycle hook, specifically postCreateCommand or later, because those run after the workspace is mounted. Getting this division right is what keeps rebuilds fast (durable work is cached) and correct (source-dependent work runs against the real mount).