Contents

Devcontainers: Reproducible Development Environments in VS Code

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

Contents

I have lost enough hours to “but it works on my machine” that I treat the phrase as a personal insult. The last time it cost me a full afternoon: a teammate cloned a repo, their Node version was a major release behind, a native dependency wouldn’t compile because they were missing a system library, and we both vanished into apt-get archaeology while the actual work sat untouched. 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.

What a devcontainer actually is

Advertisement

A devcontainer is just a Docker container that VS Code attaches to as your workspace. Your editor’s UI 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 key mental shift is where things run. When you open a terminal in a devcontainer, it’s a shell inside the container, with the container’s filesystem and PATH. The Node it finds, the system libraries it links against, the exact compiler version — all pinned by the image, identical for everyone. That’s the difference between “we documented the setup in the README” (which rots) and “the setup is the repo” (which can’t).

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
  "name": "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. remoteUser keeps you off root, which matters more than people think the first time a bind-mounted file ends up owned by root and you can’t edit it on the host.

Going further than a base image

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:

1
2
3
4
5
6
7
8
{
  "name": "api",
  "build": { "dockerfile": "Dockerfile" },
  "runArgs": ["--init"],
  "mounts": [
    "source=node-modules-vol,target=/workspaces/api/node_modules,type=volume"
  ]
}
1
2
3
4
5
6
7
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, where Docker runs in a VM and every file crosses a virtualisation boundary — is painfully slow. Putting node_modules on a named Docker volume keeps it on the container’s native filesystem, where reads are fast. The same approach pays off for Python virtualenvs, Go module caches, and Rust target/ directories. This is the single biggest performance fix most people are missing, and it’s two lines.

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

1
2
3
4
5
{
  "dockerComposeFile": "docker-compose.yml",
  "service": "app",
  "workspaceFolder": "/workspaces/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” — and no more version drift between the database a developer happens to have installed and the one production runs.

The Dev Container CLI and CI — where it earns its keep

Advertisement

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

1
2
3
4
5
6
7
$ 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 definition, a green CI run means something concrete. The class of failure where tests pass locally and explode in CI because of a toolchain mismatch simply disappears — you’ve eliminated the variable instead of debugging it.

That “define the environment once, run it identically everywhere” instinct is exactly the same one behind Dagger pipelines that run anywhere: the closer your local box is to CI, the less of your life evaporates into the gap between them. Pair a devcontainer for the editor experience with a Dagger pipeline for the build and you’ve closed both ends of the loop.

Devcontainers, Nix, and the reproducibility spectrum

Devcontainers aren’t the only way to pin an environment, and it’s worth being honest about where they sit. They give you image-level reproducibility: everyone gets the same Debian base, the same apt packages at build time, the same Node feature. That’s reproducible enough for the vast majority of teams and it’s reachable in an afternoon.

If you want bit-for-bit, “the same inputs always produce the same closure” reproducibility, that’s Nix territory — once you survive the learning curve. Nix is stricter and stronger and considerably harder to live with; devcontainers are looser and dramatically friendlier. The two aren’t even mutually exclusive — you can run Nix inside a devcontainer and get the editor integration on top of Nix’s guarantees. For most teams, plain devcontainers are the right rung on the ladder: enough reproducibility to kill “works on mine,” without asking everyone to learn a new language to add a dependency.

Where it bites

It isn’t free.

  • Cold builds are slow. The first build, and a cold pull of a fat image on a bad connection, is genuinely annoying. Prebuilding images in CI and pushing them to a registry largely fixes this, but it’s setup you have to do.
  • Docker-in-Docker is weird. It works, but if your project itself orchestrates containers, you’re now nesting runtimes and the mental model gets fiddly. Mounting the host Docker socket is the alternative, with its own security trade-offs.
  • GUI apps and hardware access — USB devices, serial ports, that sort of thing — are possible but platform-dependent and finicky. Don’t promise a smooth experience there until you’ve tested it on every OS your team uses.
  • You are, inescapably, now running Docker. If your team is hostile to containers, this is a hard sell rather than a convenience, and no config file changes that.
  • A subtle lock-in worry. The richest experience is in VS Code and its proprietary remote server. The open spec and the CLI mean you aren’t trapped — other editors are catching up and the CLI works headless — but the smoothest path today is firmly Microsoft’s.

Troubleshooting the common snags

The failures I see most, and the fixes:

  • node_modules is mysteriously empty after build. A named volume mounted at node_modules starts empty and shadows what the image built. Run your install in postCreateCommand (which fires after the volume is mounted), not in the Dockerfile (which runs before it).
  • Files created in the container are owned by root on the host. You’re running as root in the container. Set remoteUser (and a non-root USER in the Dockerfile) and, if needed, the updateRemoteUserUID option so the container user’s UID matches yours.
  • “Port 3000 already in use” on rebuild. A stale container is still holding the port. Dev Containers: Rebuild Without Cache or prune the old container; forwardPorts doesn’t free a port a zombie process is still bound to.
  • Extensions vanish or reset. Extensions listed under customizations.vscode.extensions install into the container, not your host profile. If one’s missing, it’s not in the list — add it there so the next contributor gets it too, which is the whole point.
  • The build is fine locally but fails in CI. Almost always an image you pulled by floating tag (:latest, :20) that drifted between runs. Pin digests for anything you care about being identical, and prebuild.

Prebuilding: the step that makes teams actually adopt them

The biggest adoption killer is the first-run experience. A new contributor clones, reopens in container, and then watches a multi-minute build crawl past before they can type a line of code. Do that to enough people and the team quietly decides devcontainers are “too slow” — when the real problem is that everyone is rebuilding the same image from scratch.

The fix is to build the image once in CI and push it to a registry, then have the devcontainer pull the prebuilt image instead of building locally. The devcontainer CLI has a build --push mode for exactly this, and there’s a prebuild GitHub Action that does it on a schedule. Point devcontainer.json at the published image with a digest, and a fresh clone becomes a fast pull rather than a slow build:

1
2
3
4
{
  "image": "ghcr.io/your-org/api-devcontainer@sha256:<digest>",
  "postCreateCommand": "npm ci"
}

Now the heavy lifting — apt packages, language runtimes, system libraries — is baked into a layer everyone shares, and only the cheap, per-clone postCreateCommand runs on each machine. This is the difference between “devcontainers are slow” and “devcontainers are instant,” and it’s almost entirely about where the build happens. Treat the devcontainer image like any other artefact: build it in CI, version it, pin it by digest, and let developers consume it rather than reproduce it.

A second adoption lever, smaller but real: write a .devcontainer/devcontainer.json that works with zero manual steps. No “now run this script,” no “you also need to install X on the host first.” If a contributor has to do anything beyond reopen-in-container, some fraction of them won’t, and you’re back to inconsistent environments for exactly the people you most wanted to onboard. The whole value proposition is that the environment is automatic; the moment it requires a runbook, you’ve reintroduced the README that rots. Aim for the experience where a brand-new contributor with nothing but VS Code and Docker installed can clone, reopen, and have a passing test run inside a few minutes — and then actually test that claim on a clean machine, because it’s astonishing how often “it just works” quietly depended on something already sitting on your laptop.

Verdict

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 you tell new hires.

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; don’t add Docker to a problem you don’t have. But 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, prebuild your images, and don’t be surprised by the first cold build. Get those three things right and the phrase “works on my machine” stops being something anyone in your team is allowed to say.

Advertisement
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.