How to Configure devcontainer.json from Scratch

You want a working devcontainer.json without copying a template you don't understand. This page builds one from an empty file, adding only the properties that earn their place — a pinned base, a Feature, a lifecycle hook, and the remoteUser that spec compliance requires — and validates each addition. The result is a minimal configuration you can explain line by line.

The reason to build the file this way, rather than starting from a generated template, is that a devcontainer.json is a contract the tooling reads literally. Every property you leave in has a runtime consequence: image decides what filesystem the container starts from, remoteUser decides which UID owns the files you edit, features decides what gets layered on top, and postCreateCommand decides what runs once the workspace is mounted. When you inherit a template with a dozen keys you didn't choose, a single failure — extensions that never load, a build that isn't reproducible, files you can't delete on the host — sends you reading documentation for properties you never needed. Starting from an empty object and adding one property at a time means the config never contains a line you can't account for.

Reach for this from-scratch approach whenever you are standing up a new repository's container, auditing an inherited config that behaves unpredictably, or teaching yourself which properties the specification actually requires versus which ones are conveniences. The mental model is additive and validated: begin with the two decisions the spec forces on you — where the environment comes from and who it runs as — confirm that the tooling parses what you wrote, then layer on tooling, setup commands, and editor customizations only as a concrete need appears. Each addition is followed by a read-configuration run so the file is never more than one small, reversible step away from a known-good state.

Prerequisites

You only need a container engine, the Dev Container CLI for validation, and a repository to hold the file.

  • Docker or Podman running (docker info succeeds).
  • devcontainer CLI installed (npm i -g @devcontainers/cli).
  • An empty .devcontainer/devcontainer.json in your repo.

Setup prerequisitesA running engine, the CLI, and an empty config file are all you need to start.Enginedocker infoCLIdevcontainer --versionFile.devcontainer/

None of these prerequisites are heavy, but the ordering matters. The container engine has to be running before the CLI can do anything meaningful — devcontainer up shells out to Docker or Podman, so if docker info errors, every later step fails with a message about the daemon rather than about your config, which sends you debugging the wrong layer. Install the CLI globally so devcontainer is on your PATH regardless of which directory you run it from; a project-local install works too, but then you must invoke it through npx, and the extra indirection hides version mismatches.

The detail people most often get wrong is the file's location and name. The specification looks for .devcontainer/devcontainer.json or, as a single-file alternative, .devcontainer.json at the repository root — note the leading dot in both cases. A file named devcontainer.json sitting loose in the project root, with no dot and no .devcontainer/ folder, is invisible to the tooling, and you will spend real time wondering why your carefully written properties have no effect. Start the file as an empty JSON object, {}, so read-configuration has something valid to parse from the very first run.

Step-by-Step Implementation

  1. Declare a base and remoteUser. The two required decisions: where the environment comes from, and who it runs as.
{
  "name": "From Scratch",
  "image": "mcr.microsoft.com/devcontainers/base:ubuntu@sha256:PINNED",
  "remoteUser": "vscode"
}

The image value carries both a human-readable tag, :ubuntu, and a @sha256: digest, and the digest is what actually pins the build. Tags are mutable — ubuntu can be repointed at a rebuilt image tomorrow — so a config that names only the tag is reproducible today and silently different next month. Appending the digest freezes the exact layers, which is why every reproducible config on this site pins by @sha256. The remoteUser set to vscode names the non-root account the Microsoft base images ship with; declaring it here tells the tooling to run your shell, your lifecycle commands, and your editor server as that user rather than as root. Getting these two properties right first prevents the two failure modes that are hardest to notice later: a build that drifts because nothing pinned it, and a workspace full of root-owned files because nothing set the user.

  1. Add a Feature to inject tooling without editing the base image.
{
  "features": { "ghcr.io/devcontainers/features/node:1": { "version": "20" } }
}

A Feature is a self-contained install script published as an OCI artifact, and referencing one is how you add tooling without writing a Dockerfile or dirtying the base image. The key ghcr.io/devcontainers/features/node:1 is the Feature's registry address; the :1 selects its major version, so you get patches and fixes to the Feature itself without a breaking change. The nested { "version": "20" } is an option passed into that Feature, telling it which Node runtime to install — the outer :1 and the inner version are two different version numbers and confusing them is a common early mistake. Because the Feature runs at build time as an ordered layer, it lands in the image before your workspace is mounted, which is exactly why runtime setup like installing your project's dependencies belongs in a lifecycle hook instead, covered next.

  1. Add a post-create hook for mount-dependent setup like dependency installs.
{
  "postCreateCommand": "npm ci"
}

postCreateCommand runs once, after the container is created and — critically — after your repository is bind-mounted into the workspace. That timing is the whole point of choosing this hook for npm ci: the command needs package.json and package-lock.json to be present, and those files arrive with the mount, not with the image build. Put the same install in a Dockerfile RUN step or a Feature and it executes against an empty workspace, so it either fails outright or bakes stale dependencies into the image that the mount then hides. Using npm ci rather than npm install matters here too: ci installs strictly from the lockfile and errors if package.json and the lock have drifted, which keeps the container's dependency tree identical to what the lockfile pins rather than quietly resolving new versions.

  1. Validate the merged configuration before you rely on it.
devcontainer read-configuration --workspace-folder .
devcontainer up --workspace-folder .

These two commands check different things and you want them in this order. read-configuration is the cheap, fast check: it parses the file, resolves Features, applies any variable substitution, and prints the effective merged configuration without building or starting anything. If you mistyped a key, nested a property at the wrong level, or left trailing-comma-style invalid JSON, this is where it surfaces — in seconds, with no container spun up. Only once the merged result looks right do you run devcontainer up, which actually pulls the pinned image, applies the Feature layers, creates the container, mounts the workspace, and runs postCreateCommand. Running up first would still catch errors, but it makes you wait through a build to learn about a typo you could have caught in a parse. The --workspace-folder . argument points both commands at the current directory, where they expect to find the .devcontainer/ folder.

Build orderAdd the base and user, then a Feature, then a hook, validating as you go.1base +remoteUserrequired2featurestooling3postCreateinstall deps4validateread-configuration

Common Pitfalls

The mistakes below all come from skipping a required property or the validation step.

The ownership pitfall is the one that bites hardest because it does its damage silently. When remoteUser is omitted, the container runs as root, and because your repository is bind-mounted rather than copied, every file the container writes into the workspace is created with root's UID on the host. You do not notice until you try to git commit, delete a build artifact, or edit a generated file outside the container and hit a permission-denied error, at which point you are running sudo chown to reclaim your own files. Declaring remoteUser: "vscode" from the first line, as this build does, keeps the container's writing UID aligned with the account the base image set up, so files land owned by a normal user and the host tooling never trips.

The subtler, topic-specific trap is misplacing customizations. It is intuitive to write an extensions array or a settings block at the top level of the object, since that is where editor settings live in other files — but the specification only reads editor configuration under customizations.vscode. Put it at the root and the JSON still parses, read-configuration still succeeds, and the container still builds; the extensions and settings are simply ignored with no error to point you at the cause. This is precisely why read-configuration earns its place in the loop: printing the merged result shows you whether a property landed where the tooling actually looks for it, rather than leaving you to infer from behavior that a silently-ignored key was the problem.

Config triageA short triage from a failing parse to a minimal compliant config.Does read-configuration parse cleanly?NOFix the flagged keyIs remoteUser declared?NOAdd it — avoids root-owned filesMinimal compliant config

SymptomRoot CauseRemediation
Extensions ignoredDeclared at root, not customizations.vscodeMove under customizations.vscode
Root-owned files on hostremoteUser omittedDeclare remoteUser
Build not reproducibleFloating tag instead of digestPin image by @sha256
npm ci fails on createRan before the mountKeep installs in postCreateCommand

Conclusion

Start with the two required decisions — base and remoteUser — then add only what earns its place, validating with read-configuration after each change. A config you built this way is one you can debug, because every line has a reason.

The strategic payoff shows up the first time something breaks. A four-property file whose every key you chose deliberately turns a failure into a short list of suspects: if the build isn't reproducible you look at the @sha256 digest, if files are root-owned you look at remoteUser, if a dependency install fails you look at whether it belongs in postCreateCommand or ran too early. An inherited template offers no such map — you first have to learn what each of its keys does before you can even form a hypothesis. Minimalism here is not aesthetic restraint; it is a diagnostic property of the file.

This from-scratch method is the same pin-and-cache discipline that governs reproducible environments generally, expressed at the level of a single config file. Pinning the image by digest is the pin; putting mount-dependent work in a lifecycle hook keeps the expensive image layers cacheable while still letting per-project setup run fresh each time; validating with the CLI is the check that the pinned, cached result matches intent. Because the file stays small and every property is accounted for, it also stays portable: the exact same devcontainer.json drives an editor, a headless CLI build, and CI, so the environment a teammate opens and the environment your pipeline builds are the one you wrote and validated here.

Minimal vs optionalA compliant config always has a base, a user, and validation; everything else is additive.Always includeimage or buildremoteUservalidated configAdd as neededfeatureslifecycle hookscustomizations

FAQ

Do I need a Dockerfile, or is an image enough? An image reference is enough for most setups and is the simplest start. Reach for a build block with a Dockerfile only when you need custom OS packages or a non-root user the base image doesn't provide. You can always graduate from image to build later without changing the rest of the config. In practice, prefer a Feature over a Dockerfile whenever the thing you need is a language runtime or a common tool, because a Feature stays declarative and version-selectable in the JSON. Keep the Dockerfile in reserve for genuinely image-specific work — apt packages, system libraries, a custom user — that Features can't express.

Why must remoteUser be in even a trivial config? Because without it 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 keeps ownership predictable and is a spec-compliance requirement across every example on this site. It is worth distinguishing remoteUser from containerUser: containerUser sets the account the container's main process runs as, while remoteUser sets the account your interactive shells, lifecycle commands, and editor server run as — and it's the latter that determines who owns the files you edit. For the from-scratch config here, remoteUser: "vscode" is enough, because that user already exists in the Microsoft base images with a sensible UID.

How do I know the config actually does what I think? Run devcontainer read-configuration --workspace-folder .. It resolves the merged configuration — base, Features, and variable substitution — and prints the effective result, so you validate what the tooling will actually build rather than what you assume the file means. Treat its output as the source of truth over the raw file: it shows the post-merge state after Features have contributed their own settings and after ${localWorkspaceFolder}-style variables are substituted. When a property seems to have no effect, diffing what you wrote against what read-configuration prints is the fastest way to see whether the key was ignored, overridden, or landed at the wrong nesting level.

Can I put comments in devcontainer.json? Yes. The specification parses the file as JSONC — JSON with comments — so both // line comments and /* */ block comments are legal, as are trailing commas. This is useful in a from-scratch config: it lets you annotate why a given digest was pinned or why an install lives in postCreateCommand, right next to the property. Note that some unrelated tools expect strict JSON, so feeding the file to a generic parser rather than the Dev Container CLI may trip on the comments.

Does property order in the file matter? No — devcontainer.json is a JSON object, so the tooling reads properties by key, not by position, and reordering image, remoteUser, features, and postCreateCommand changes nothing about the result. What does have an order is execution: Features apply during the build, then the container is created and the workspace mounted, then lifecycle hooks like postCreateCommand run. Ordering the keys to mirror that runtime sequence is purely a readability choice, not something the parser enforces.