The Day We Stopped Fighting Our Own Tools: A Team's Journey to Dev Containers
A story about broken environments, lost hours, and the configuration file that finally made everyone's machine the same machine.
Monday Morning, 9:03 AM
Karim had been a senior engineer for six years. He had shipped microservices, survived two major database migrations, and once debugged a race condition that only appeared in production on Tuesdays. He was not the kind of person who got rattled easily.
But on this particular Monday morning, he was staring at a Slack message from the newest member of his team, Sara, that read:
"Hey, I cloned the repo and ran
docker compose up --build -dlike the README says but the backend keeps crashing. Also npm can't find some packages. Also my Postgres won't start. Is there a setup guide?"
Karim closed his laptop, took a breath, and opened it again.
This was not Sara's fault. This was everyone's fault. Or more precisely — this was the fault of a development setup that had accumulated two years of tribal knowledge, unwritten steps, and the quiet assumption that every developer's laptop would somehow be configured identically to the last person's.
The State of Things Before
Their stack was a Node.js backend, a React frontend, and a PostgreSQL database. The Docker setup was solid for production. The docker-compose.yml at the root of the repo would spin up the database and the app, and it worked beautifully — in CI, in staging, in production.
The problem was never running the app. The problem was developing it.
Here is what the README said:
1. Clone the repo
2. Run docker compose up --build -d
3. Run npm install
4. Run npm run dev
Here is what the README did not say:
- After
docker compose up, you still need to runnpm installon your host machine to get editor features like intellisense and linting working locally. Thatnpm installinstalls packages compiled for your host OS and architecture. On an M2 Mac the binaries are different from an Intel Linux box — and certain native modules behave differently or fail silently. - The VSCode ESLint extension version matters. Project-level
eslint.jsoncontrols the rules, but it's the extension that shows red underlines as you type. An outdated extension from a previous project can silently ignore rules. There's no enforcement — the editor just quietly does less than you expect. - The
postCreateCommandin adevcontainer.jsoncan automatenpm installinside the container, but there's no equivalent mechanism with plain Docker Compose. Each developer has to remember the manual steps after cloning. - Any global CLI tools the project depends on — database migration runners, code generators, custom scripts — need to be installed locally by each developer. There's no single place to declare them for the whole team.
None of the real problems were documented because none of them had ever been a problem for the people who wrote the README. They had set up their machines once, years ago, and it all just worked. The knowledge had evaporated from their heads long before it could reach a README.
Sara spent her entire first day not writing code. She spent it fighting her environment.
The Real Cost
It is tempting to dismiss this as an onboarding problem — something that only hurts new people, and only once. But Karim knew better.
Three months prior, he had switched laptops. He had spent a day and a half reconstructing his development environment from memory. He got most of it right. He did not get all of it right. He forgot to install a global CLI tool the project used for database migrations. For two weeks he was running migrations manually because the script that was supposed to automate it silently failed on his machine. He fixed it when a teammate noticed. He never thought to check whether his tooling matched the repo's assumptions, because there was nowhere in the repo that stated what those assumptions were.
The month before that, the team had upgraded the app from Node 16 to Node 18. The Docker image was updated in an afternoon. But every developer also needed to update their local Node version — because they were still running npm install locally to get editor features working. Making sure everyone had updated, that nvm use was being respected, that no one was accidentally running scripts against the old version — that took a week of sporadic Slack messages. One developer's editor kept using Node 16 for intellisense while the container ran Node 18. Their autocomplete suggestions were subtly wrong for two sprints before anyone noticed.
This is the hidden tax of an inconsistent development environment. It is not one big cost. It is a thousand small costs, distributed across every developer, every week, invisibly.
Docker Compose Was Not Enough — And Here's Why
When Karim brought this up in a team retrospective, the first response was predictable: "We already use Docker. The app runs in a container."
He had expected this. He drew a diagram on the whiteboard.
What Docker Compose gives you:
┌─────────────────────────────────────┐
│ Your Laptop │
│ │
│ ┌───────────────────────────┐ │
│ │ Docker Container │ │
│ │ (App runtime here) ✅ │ │
│ │ (Node + npm here) ✅ │ │
│ └───────────────────────────┘ │
│ │
│ Your Editor ← host ⚠️ │
│ Your CLI tools ← host ⚠️ │
│ VSCode extensions ← host ⚠️ │
└─────────────────────────────────────┘
The app runtime is consistent for everyone — that's what Docker Compose already solved. But the development toolchain still runs on the host. Your editor's Node version for intellisense, the global CLI tools you use during development, the VSCode extensions — all of these vary between developers' machines.
To be precise: ESLint rules defined in eslint.json apply to everyone equally, because they live in the project. The inconsistency is not the rules themselves, it's the tooling that enforces them. An outdated ESLint extension shows different editor feedback than a current one. A missing global CLI tool means a developer silently skips a workflow step. A mismatched local Node version means npm install produces different binaries than what runs in the container.
Docker Compose solved the "it works on my machine" problem for running the app. It did not solve it for building the app day to day.
A dev container closes that gap.
What a Dev Container Actually Is
A dev container is Docker, but for your editor. Instead of your code editor running on your laptop and connecting to a containerized app, your code editor moves inside the container. The terminal is inside. The language server is inside. ESLint runs inside. The Node version inside is the only Node version that matters, because it is the only one being used.
From the outside, nothing feels different. You open VSCode, you see your files, you type code. But underneath, every tool that touches your code is running in the same container, on the same OS, with the same Node version, with the same ESLint rules — for every developer on the team.
The configuration lives in two files checked into the repository: a devcontainer.json and a docker-compose.yml inside a .devcontainer/ folder. When someone clones the repo and opens it in VSCode, they get a popup: Reopen in Container. They click it. That is the entire setup process.
The Migration
Karim proposed the migration to the team. There was some skepticism — the Docker Compose setup worked, mostly, and people were wary of adding another layer of abstraction. He made a simple argument: the complexity already existed. It was just invisible, living in everyone's local environment instead of in a file.
The team agreed to try it.
Step 1: The Folder Structure
The first thing they did was create the .devcontainer/ folder at the root of the repo:
my-app/
├── .devcontainer/
│ ├── devcontainer.json
│ └── docker-compose.yml
├── src/
├── package.json
└── .env
Note that the docker-compose.yml inside .devcontainer/ is separate from any existing production compose file at the root. The dev container compose file is only for development. Production infrastructure stays separate.
Step 2: The Docker Compose File
services:
app:
image: mcr.microsoft.com/devcontainers/javascript-node:18
volumes:
- ..:/workspace:cached
command: sleep infinity
env_file:
- ../.env
depends_on:
- db
networks:
- dev-network
db:
image: postgres:15
restart: unless-stopped
env_file:
- ../.env
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- dev-network
networks:
dev-network:
driver: bridge
volumes:
postgres-data:
A few things worth noting here. The app service uses Microsoft's official dev container base image for Node 18. It includes Node, npm, git, and common developer tooling — all pre-installed, all at the right version. The command: sleep infinity keeps the container alive while you work inside it. The volume mount ..:/workspace:cached mounts the project root into /workspace inside the container, so your files are live-synced.
The env_file directive is important. It tells Docker to load environment variables from the .env file in the project root. This is how DATABASE_URL and other secrets get into the container without being hardcoded.
The networks block is not optional. Without an explicit shared network, the app and db containers cannot reach each other by service name. Docker Compose creates a default network, but VSCode's dev container tooling can interfere with this. Defining dev-network explicitly and attaching both services to it ensures the hostname db always resolves correctly from inside the app container.
Step 3: The devcontainer.json
{
"name": "My App Dev Environment",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspace",
"runServices": ["app", "db"],
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"ms-azuretools.vscode-docker",
"ckolkman.vscode-postgres"
],
"settings": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"eslint.validate": ["javascript", "typescript"]
}
}
},
"forwardPorts": [3000, 5432],
"postCreateCommand": "npm install",
"remoteUser": "node"
}
This file is where the magic is declared. dockerComposeFile points to the compose file in the same directory. service tells VSCode which container to attach the editor to — in this case, app. workspaceFolder sets the working directory inside the container.
The runServices array is critical and easy to miss. Without it, VSCode might only start the app container and leave db dormant, causing the database connection to fail immediately with a confusing ENOTFOUND db error. Listing both services ensures they all start together when the dev container opens.
The customizations.vscode.extensions array is perhaps the most immediately valuable part for a team. Every extension listed here is automatically installed for every developer who opens the project. No more "make sure you have the ESLint extension installed." No more formatter inconsistencies because someone has an older Prettier version. The extensions are pinned and automatic.
postCreateCommand runs once after the container is built for the first time. npm install here means no developer ever has to remember to run it manually — and it runs inside the container, so the installed packages are built for the container's architecture, not the host machine's.
Step 4: The .env File
# App
DATABASE_URL=postgresql://postgres:postgres@db:5432/mydb
# Postgres
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=mydb
The database hostname in DATABASE_URL is db — the service name from docker-compose, not localhost and not host.docker.internal. Inside the dev container network, Docker's internal DNS resolves db to the Postgres container's IP automatically. Using localhost or 127.0.0.1 points to the app container itself, which has no Postgres running. Using host.docker.internal points to the host machine, which also has no Postgres running (unless you have a local install). The service name is the only correct answer.
The Pitfalls They Hit (And How to Debug Them)
The migration was not without friction. Here are the problems the team encountered, in the order they encountered them.
Pitfall 1: ECONNREFUSED 127.0.0.1:5432
The first error Sara hit after the team shipped the dev container config:
Error: connect ECONNREFUSED 127.0.0.1:5432
The address 127.0.0.1 was the giveaway. This means DATABASE_URL was either empty or being parsed as localhost. The fix was to confirm the env_file directive in docker-compose was pointing to the right relative path. The .devcontainer/docker-compose.yml file sits one level deeper than the .env file, so the path must be ../.env, not ./.env.
After fixing the path, running echo $DATABASE_URL inside the container terminal confirmed the variable was loading correctly.
Pitfall 2: ENOTFOUND db
After fixing the env file path:
Error: getaddrinfo ENOTFOUND db
This one means the DATABASE_URL is loading correctly — the app is trying to reach db — but the db container is either not running or not on the same network. The diagnosis is to run docker ps on the host machine and check whether both containers appear, and whether the Postgres container's status is Up or Restarting.
In one case, the Postgres container was restarting because the team had forgotten to add runServices to devcontainer.json, so the db service never started. In another case, the network was missing, and the two containers existed on separate isolated networks.
Pitfall 3: Postgres Crashing in a Loop
FATAL: database files are incompatible with server
DETAIL: The data directory was initialized by PostgreSQL version 14,
which is not compatible with this version 15.
This happened because one developer had previously run a Postgres 14 container with a host-mounted volume at a path that the new config reused. Postgres data directories are version-specific. A Postgres 15 container cannot read data initialized by Postgres 14.
The fix depends on whether the existing data matters. If it does not, deleting the old volume data and letting Postgres reinitialize solves it instantly. If it does, downgrading the image to match the existing data version is the safer path. Using named Docker volumes instead of host path mounts avoids this class of problem entirely, because named volumes are managed by Docker and don't linger between projects.
Pitfall 4: The Container Rebuilt But Nothing Changed
After modifying docker-compose.yml or devcontainer.json, changes do not take effect by reopening the folder. The dev container must be rebuilt. In VSCode, this is done via the Command Palette: Dev Containers: Rebuild Container. "Reopen in Container" only reconnects to an existing container. "Rebuild Container" tears it down and starts fresh.
Docker Compose Alone vs Docker Compose + Dev Container: What Actually Changes
After running the new setup for two months, the team had a clear picture of what the dev container layer actually added — and they were honest about where the line was.
One thing worth clarifying first: dev containers do not replace Docker Compose. A dev container uses Docker Compose internally. The .devcontainer/docker-compose.yml is still a compose file — it still defines services, networks, and volumes the same way. What devcontainer.json adds is a layer on top: it tells VSCode which service to attach the editor to, which extensions to install, and which commands to run after setup. Docker Compose is the foundation. The dev container configuration is the part that makes the editor aware of it.
So the real question is not "which one" but "do I need the extra layer."
Docker Compose alone handles everything about running your stack reliably — the app, the database, the services, the networking between them. If your only goal is reproducible runtime behavior, it is sufficient. You do not need anything else on top of it.
The dev container layer adds value in a specific area: the developer's local experience outside the container. Specifically, it pins VSCode extension versions so every developer gets identical editor behavior, it runs postCreateCommand automatically so no manual setup steps get forgotten after cloning, and it pre-installs any global CLI tools the project depends on — all declared in a file that lives in the repo alongside the code. These are things Docker Compose was never designed to touch, because Docker Compose was designed for running apps, not for configuring editors.
What the dev container layer does not change: ESLint rules still come from eslint.json and were consistent before. The .env file still has to be created manually. The Node runtime version was already locked by the Docker image.
The honest question is not "should I use dev containers" but "do I have the problem they solve." If environment inconsistencies across your team are costing real time — onboarding friction, mysterious local failures, CLI tool drift, extension version mismatches — the dev container layer pays for its added complexity. If your team's local setup is already smooth, it introduces overhead without proportional benefit.
Six Weeks Later
Sara's second day on the team looked nothing like her first.
She cloned the repo. VSCode detected the .devcontainer folder and showed a notification: Reopen in Container. She clicked it. The container built in about two minutes — longer on the first run because it was pulling images, near-instant on subsequent runs because Docker caches the layers.
When the container finished building, npm install had already run. The ESLint and Prettier extensions were already installed. The Postgres database was already running. She opened the terminal and typed npm run dev. The app started. She made a code change. The formatter ran on save. The linter caught an issue before she even ran the code.
She sent a message to Karim: "Okay this is actually kind of magic."
It wasn't magic. It was a devcontainer.json and a compose file, checked into the repo alongside the code they described. The configuration that had previously lived invisibly in everyone's local environment — in shell profiles, in global npm configs, in editor settings documents no one read — now lived in the repository, versioned, reviewable, and automatically applied.
The environment had become code. And like any good code, it worked the same way every time.
If you found this useful, the full configuration from this post is summarized below for quick reference.
Quick Reference: The Complete Setup
my-app/
├── .devcontainer/
│ ├── devcontainer.json
│ └── docker-compose.yml
├── src/
├── package.json
└── .env
.devcontainer/docker-compose.yml
services:
app:
image: mcr.microsoft.com/devcontainers/javascript-node:18
volumes:
- ..:/workspace:cached
command: sleep infinity
env_file:
- ../.env
depends_on:
- db
networks:
- dev-network
db:
image: postgres:15
restart: unless-stopped
env_file:
- ../.env
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- dev-network
networks:
dev-network:
driver: bridge
volumes:
postgres-data:
.devcontainer/devcontainer.json
{
"name": "My App Dev Environment",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspace",
"runServices": ["app", "db"],
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
],
"settings": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}
},
"forwardPorts": [3000, 5432],
"postCreateCommand": "npm install",
"remoteUser": "node"
}
.env
DATABASE_URL=postgresql://postgres:postgres@db:5432/mydb
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=mydb