📦 Dev Containers: Stop Saying "It Works on My Machine"
"Works on my machine" is the phrase that ends friendships, derails deployments, and turns a two-hour onboarding into a two-day yak-shaving marathon. You've lived it. New engineer joins the team, spends day one installing the right Node version, day two figuring out why npm install explodes, and day three realizing the README was last updated in 2019 and now refers to a tool that no longer exists.
The root cause is always the same: local development environments are snowflakes. Every developer's machine is a unique combination of OS versions, package managers, shell configs, and deeply personal .bash_profile crimes. Production runs in a container. Your laptop does not.
Dev containers fix this. Not perfectly, not magically — but practically. Let me show you how.
What Is a Dev Container?
A dev container is a Docker container that you do your development inside. Your editor (VS Code, Cursor, JetBrains) runs on your host machine, but the terminal, the language runtime, the database, the linter, the test runner — all of that runs inside a container defined by a devcontainer.json file checked into the repo.
The key insight: the dev environment is code. It lives next to your application code, gets reviewed like your application code, and changes when your application code changes.
If you onboard a new developer, they clone the repo, open it in VS Code, click "Reopen in Container," and they're running in the exact same environment as everyone else. No README, no setup script, no tribal knowledge required.
At Cubet, we adopted dev containers for a complex Laravel + Node + React monorepo that had accumulated years of "you need to install this one native extension manually" debt. Onboarding went from half a day of pain to under twenty minutes. That's the pitch.
The devcontainer.json File
Everything starts with .devcontainer/devcontainer.json:
{
"name": "My API",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspace",
"features": {
"ghcr.io/devcontainers/features/node:1": { "version": "20" },
"ghcr.io/devcontainers/features/github-cli:1": {}
},
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"ms-azuretools.vscode-docker"
],
"settings": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}
},
"postCreateCommand": "npm install",
"remoteUser": "node"
}
A few things worth noting here:
featuresare pre-built dev container feature bundles. You pull in Node 20, the GitHub CLI, whatever you need, without writing a custom Dockerfile for each tool.customizations.vscodemeans every developer on the team gets the same extensions and settings automatically. No more "you need to install ESLint manually" in the README.postCreateCommandruns once after the container is created. Use it fornpm install,bundle install, database migrations — whatever gets the project to a runnable state.
Composing the Full Stack
Real projects aren't just an app — they need a database, a cache, maybe a message queue. Here's where dev containers get seriously powerful. Pair devcontainer.json with a docker-compose.yml that defines the whole local stack:
# .devcontainer/docker-compose.yml
version: "3.9"
services:
app:
build:
context: ..
dockerfile: .devcontainer/Dockerfile
volumes:
- ..:/workspace:cached
- node_modules:/workspace/node_modules
command: sleep infinity
environment:
DATABASE_URL: postgres://dev:dev@db:5432/appdb
REDIS_URL: redis://cache:6379
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: dev
POSTGRES_PASSWORD: dev
POSTGRES_DB: appdb
volumes:
- postgres_data:/var/lib/postgresql/data
cache:
image: redis:7-alpine
volumes:
node_modules:
postgres_data:
Notice node_modules as a named volume. This is a critical trick: if you mount your entire workspace from the host, node_modules on macOS runs through the OSXFS layer and becomes painfully slow. Isolating it to a named volume means npm installs happen at Linux speed inside the container. Your npm run dev goes from "I can get a coffee" to "it's already hot-reloading."
Dockerfile for the Dev Container
The devcontainer.json points to a Dockerfile for the development image. Unlike your production Dockerfile, the dev one should be fat and friendly:
# .devcontainer/Dockerfile
FROM node:20-bookworm-slim
RUN apt-get update && apt-get install -y \
git \
curl \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*
# Install global tooling once at image build time
RUN npm install -g tsx ts-node
WORKDIR /workspace
# Non-root user (node user ships with the base image)
USER node
You're not worried about image size here — this never ships to production. Include the postgres client so you can psql directly from the terminal. Add git so git operations work. Add curl for debugging. Your prod image stays lean; your dev image is a comfortable workshop.
Lifecycle Scripts: More Than Just postCreateCommand
There are several lifecycle hooks worth knowing:
postCreateCommand: runs once when the container is first created. Good fornpm install.postStartCommand: runs every time the container starts. Good for starting background watchers.postAttachCommand: runs when you attach an editor session. Good for displaying a "you're ready" message.initializeCommand: runs on the host before the container starts. Good for pulling secrets from 1Password or Vault into a.envfile that the container then reads.
That last one is underused. Instead of checking .env files into the repo (a bad habit) or writing a README step that says "ask someone for the .env," use initializeCommand to script the secret-fetch once. Everyone gets their secrets, and it's reproducible.
What It Looks Like in Practice
After you've set this up, the developer experience is:
git clonethe repo- Open in VS Code
- VS Code detects
.devcontainer/and prompts "Reopen in Container" — click it - First time: the image builds (takes a few minutes, cached after that)
- You're in the container.
npm run devworks. The database is running. The linter is installed. The right Node version is active.
For subsequent opens: container starts in ~5 seconds. Everything just works.
The Limitations (Be Honest)
Dev containers aren't free:
- Docker Desktop on macOS has performance quirks. Volume mounts are slower than native. The
node_modulesnamed volume trick above is a patch, not a fix. For very I/O-heavy workflows, you might still feel it. - GPU access is awkward. If you're doing ML work, getting GPU passthrough set up is non-trivial.
- Image build times on CI. If you're rebuilding the dev container in CI (for integration tests that mirror the dev env), you need a solid caching strategy or it'll eat your pipeline time.
- Not everyone adopts. Dev containers require VS Code, Cursor, or a JetBrains IDE with the right plugin. The engineer who lives in vim and refuses to change will route around it.
That last one is the organizational challenge, not the technical one. Tools only help if people use them.
The Bigger Win
The reason I push dev containers on every new project at Cubet isn't really onboarding time. It's consistency between development and production. When your dev container runs the same Node version, the same Linux base, the same dependencies as your production container, a whole class of "but it worked locally" bugs simply doesn't happen.
The environment is code. Version it, review it, keep it in sync. Your future teammates — and your future self at 2am debugging a staging issue — will thank you.
If your team is still sharing a setup Notion doc that's six steps out of date, try adding a .devcontainer/ directory to your next project. The first setup takes a couple of hours. The time it saves starts paying back immediately.
What's your dev environment setup right now — bare metal, dotfiles, VMs, or already on dev containers? Genuinely curious what's working.