Devcontainers: Reproducible Development Environments in VS Code

One config file, the same toolchain on every machine, no more "works on mine"

I have lost enough hours to “but it works on my machine” that I treat the phrase as a personal insult. Someone clones a repo, the Node version is wrong, a native dependency won’t compile because they’re missing a system library, and an afternoon evaporates into apt-get archaeology. Devcontainers fix this by moving the whole development environment into a container described by a file that lives in the repo. Clone, reopen, build, work. That’s the promise, and after a couple of years of leaning on them I’ll say it mostly delivers — with some sharp edges I’ll be honest about.

Advertisement

A devcontainer is just a Docker container that VS Code attaches to as your workspace. Your editor runs on the host; a small server component runs inside the container; your code, terminal, extensions, and language servers all execute in there. The contract is a single file, .devcontainer/devcontainer.json, optionally next to a Dockerfile or docker-compose.yml.

The minimal version pulls a prebuilt image and bolts on “features” — reusable installable chunks like a language runtime or a CLI:

{
  "name": "vors-api",
  "image": "mcr.microsoft.com/devcontainers/base:debian-12",
  "features": {
    "ghcr.io/devcontainers/features/node:1": { "version": "20" },
    "ghcr.io/devcontainers/features/docker-in-docker:2": {}
  },
  "forwardPorts": [3000, 5432],
  "postCreateCommand": "npm ci",
  "customizations": {
    "vscode": {
      "extensions": [
        "dbaeumer.vscode-eslint",
        "esbenp.prettier-vscode"
      ],
      "settings": {
        "editor.formatOnSave": true
      }
    }
  },
  "remoteUser": "vscode"
}

That’s the whole thing. postCreateCommand runs once after the container is built — installing dependencies, running migrations, whatever. The customizations.vscode.extensions list means a new contributor gets the same linter and formatter without anyone nagging them in code review.

The base image plus features approach is great until you need a system library that no feature provides. Then you write a Dockerfile and point at it:

{
  "name": "vors-api",
  "build": { "dockerfile": "Dockerfile" },
  "runArgs": ["--init"],
  "mounts": [
    "source=vors-node-modules,target=/workspaces/vors-api/node_modules,type=volume"
  ]
}
FROM mcr.microsoft.com/devcontainers/javascript-node:20-bookworm

RUN apt-get update && apt-get install -y --no-install-recommends \
        libvips-dev imagemagick \
    && rm -rf /var/lib/apt/lists/*

USER vscode

That mounts line is a trick worth knowing. Bind-mounting your source into the container is convenient but node_modules over a host bind mount (especially on macOS or Windows) is painfully slow. Putting node_modules on a named Docker volume keeps it on the container filesystem, where reads are fast. The same approach helps for Python virtualenvs, Go module caches, and Rust target directories.

For anything that needs a database or a queue, drop a compose file in and reference a service:

{
  "dockerComposeFile": "docker-compose.yml",
  "service": "app",
  "workspaceFolder": "/workspaces/vors-api"
}

VS Code starts the whole stack, attaches to app, and your Postgres is just postgres:5432 on the compose network. No more “install Postgres on your laptop and remember to start it.”

The format isn’t locked to VS Code. There’s a devcontainer CLI that can build and run the same config headless, which means your CI can use the exact environment your developers do:

$ npm install -g @devcontainers/cli
$ devcontainer up --workspace-folder .
$ devcontainer exec --workspace-folder . npm test
[+] Building 18.4s ...
> [email protected] test
> jest --runInBand
Tests:       42 passed, 42 total

This is the bit that converted me from “nice editor feature” to “actually load-bearing infrastructure.” When the dev environment and the CI environment are the same container, a green CI run means something. The class of failure where tests pass locally and explode in CI because of a toolchain mismatch simply disappears.

It isn’t free. The first build can be slow, and a cold pull of a fat image on a bad connection is genuinely annoying. Docker-in-Docker works but adds a layer of weirdness if your project itself orchestrates containers. GUI apps and hardware access (USB devices, that sort of thing) are possible but fiddly and platform-dependent. And you are, inescapably, now running Docker — if your team is hostile to containers, this is a hard sell rather than a convenience.

There’s also a subtle lock-in worry: the richest experience is in VS Code and its proprietary remote server. The open spec and CLI mean you aren’t trapped, but the smoothest path is firmly Microsoft’s.

If your project has any non-trivial system dependencies, more than one contributor, or onboarding pain, devcontainers are worth the setup cost — and the cost is genuinely small once you’ve written your first one. The payoff is that “clone and go” stops being a lie. They’re least useful for solo work on a single tidy language with no native deps, where a .nvmrc and a virtualenv already do the job. For teams, for open source projects that want frictionless contributions, and for anyone who self-hosts a fleet of services and wants matching dev and CI environments, I reach for them without hesitation. Just keep your dependency caches on named volumes and don’t be surprised by the first build.

Advertisement

Related Content

Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.