Dev Containers: Reproducible Node.js Development Environments Your Whole Team Can Ship With
The dev environment that works on your machine but not on your teammates costs hours of setup time and silently diverges until production breaks. Here is the VS Code Dev Containers setup that gives every engineer the same Node.js version, Postgres port, and system dependencies from a single JSON file.
You joined a new team. The README says “run npm install && npm run dev”. Twenty minutes later you are installing a specific version of Postgres via Homebrew, fighting with an obscure OpenSSL version mismatch, and discovering that the project depends on a system library nobody thought to document. By the time you ship your first PR, your dev environment already diverged from the last person who touched this project. Two weeks later a CI build fails on a Node.js version difference that nobody noticed until the pipeline caught it.
This is the default state of software development. Every engineer replicates the setup manually, and every replication introduces drift. The fix is not a better README. The fix is a single .devcontainer/devcontainer.json file that defines your environment as code, builds it in Docker, and opens VS Code inside the container. No manual steps. No drift. No “works on my machine.”
This post walks you through a production-grade dev container setup for a Node.js + Postgres project. You will end with a configuration your whole team can copy, commit, and forget.
What a dev container actually is
A dev container is a Docker container that VS Code (or GitHub Codespaces, or any devcontainer-compatible tool) opens as your full development environment. Your editor runs inside the container. The file system, terminal, extensions, language servers, and runtime tools all live there. Your laptop only runs VS Code itself and Docker.
The key files are:
.devcontainer/devcontainer.json— the configuration that tells VS Code how to build and open the container.devcontainer/Dockerfile— (optional) custom image instructions on top of a base image.devcontainer/docker-compose.yml— (optional) multi-service setup with databases, caches, and other dependencies
The workflow is simple: clone the repo, open the folder in VS Code, click “Reopen in Container” when prompted, and wait a few minutes for the first build. After that, every engineer on the team has an identical environment.
The naive devcontainer.json that most teams start with
Here is what most tutorials recommend:
{
"name": "My Project",
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm",
"forwardPorts": [3000],
"postCreateCommand": "npm install",
"customizations": {
"vscode": {
"extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
}
}
}
This works. It gives you Node.js 22, installs dependencies, and forwards port 3000. But it misses almost everything that makes dev containers actually useful in a real team: database dependencies, automatic tool configuration, lifecycle hooks for seeding data, proper volume mounts, and environment variable handling. In practice, this minimal setup will drift just as fast as a manual one, because nothing enforces that the Dockerfile matches production or that the extensions stay in sync.
Building a production-grade dev container
Let me walk through the full setup I use on every project now. It assumes a Node.js 22+ application with Postgres, but the pattern generalizes to any stack.
Step 1: The Dockerfile
Start with a custom Dockerfile that pins every system dependency your project needs. Do not rely on the base image having everything you need.
# .devcontainer/Dockerfile
ARG VARIANT=22-bookworm
FROM mcr.microsoft.com/devcontainers/typescript-node:1-${VARIANT}
# Install additional system packages your project needs
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends \
postgresql-client \
redis-tools \
curl \
jq \
&& apt-get autoremove -y && apt-get clean -y && rm -rf /var/lib/apt/lists/*
# Install a global tool your project uses
RUN npm install -g pnpm@latest-10
# Set up a non-root user for better file permissions
ARG USERNAME=node
ARG USER_UID=1000
ARG USER_GID=1000
RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \
groupmod --gid $USER_GID $USERNAME \
&& usermod --uid $USER_UID --gid $USER_GID $USERNAME; \
fi
# Set the shell to bash with color prompt
ENV SHELL=/bin/bash \
TERM=xterm-256color
The key detail here is postgresql-client and redis-tools. You should install client tools inside the container, not run them from your host machine. This guarantees every engineer has the exact same psql version for running migrations or inspecting data. The jq install is a quality-of-life win for anyone debugging JSON responses from the terminal.
Step 2: The docker-compose.yml
For anything beyond a single-process project, use Docker Compose to define your services. The app container mounts your source code and connects to a database container that lives on a private network.
# .devcontainer/docker-compose.yml
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
args:
VARIANT: 22-bookworm
volumes:
- ..:/workspace:cached
- node_modules:/workspace/node_modules
command: sleep infinity
environment:
DATABASE_URL: postgresql://app:app@db:5432/app
REDIS_URL: redis://redis:6379
NODE_ENV: development
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
networks:
- dev
db:
image: postgres:16-alpine
restart: unless-stopped
volumes:
- postgres-data:/var/lib/postgresql/data
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U app']
interval: 5s
timeout: 5s
retries: 10
networks:
- dev
redis:
image: redis:7-alpine
restart: unless-stopped
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
timeout: 3s
retries: 5
networks:
- dev
volumes:
node_modules:
postgres-data:
networks:
dev:
Three patterns here worth calling out:
Volume for node_modules. Mounting a named volume at /workspace/node_modules prevents the container from writing node_modules into your host filesystem. This avoids permission conflicts between the container user and your host user, and it speeds up file watchers because the host does not need to sync thousands of module files.
Health checks on dependencies. The depends_on.condition: service_healthy block means the app container waits for Postgres and Redis to actually accept connections before starting. Without this, your app starts, fails to connect, and you spend the next five minutes wondering why the database is unreachable.
No host port mapping. You do not need ports: "5432:5432" on the db service. Your app connects over the internal Docker network at db:5432. Exposing the port to the host is an optional convenience for running psql from your host machine, but it creates port conflicts when two projects both map 5432.
Step 3: The devcontainer.json
Now wire everything together with the config file that VS Code reads.
{
"name": "My Project Dev Container",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspace",
"shutdownAction": "stopCompose",
"postCreateCommand": "pnpm install && pnpm run db:migrate && pnpm run db:seed",
"postStartCommand": "pnpm run dev",
"forwardPorts": [3000, 5173],
"containerEnv": {
"SHELL": "/bin/bash"
},
"remoteUser": "node",
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"bradlc.vscode-tailwindcss",
"EditorConfig.EditorConfig",
"mtxr.sqltools",
"mtxr.sqltools-driver-pg",
"bierner.github-markdown-preview"
],
"settings": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"files.eol": "\n",
"[javascript][typescript][javascriptreact][typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}
}
}
}
Walk through the key fields:
dockerComposeFileandservice. Tells VS Code to use the Docker Compose setup and attach to theappservice. The db and redis services run alongside without needing their own VS Code connections.shutdownAction: "stopCompose". When you close VS Code, it tears down all services including the database. Without this, the Postgres container keeps running and consuming resources.postCreateCommand. Runs once after the container is built. This installs dependencies, runs migrations, and seeds test data. New engineers get a working database with sample data on their first build.postStartCommand. Runs every time the container starts (including after a host reboot). This launches the dev server automatically. If you do not want the dev server to auto-start, remove this and run it manually from the integrated terminal.remoteUser: "node". Runs inside the container as thenodeuser, not root. Files created from within the container will have correct permissions on the host.forwardPorts. Maps container ports to your local machine so you can openlocalhost:3000in your browser and hit the dev server running inside the container.customizations.vscode.extensions. The list of extensions that install automatically inside the container. These live in the container, not on your host VS Code, so your global extension set stays clean.customizations.vscode.settings. Editor settings that apply only when working in this project. Format-on-save, ESLint auto-fix, and line endings are the ones that cause the most “but it looked fine in my editor” CI failures.
Step 4: The .env handling
Do not hardcode secrets in devcontainer.json. Use a .env file that sits outside the container but gets loaded on startup.
# .devcontainer/.env (gitignored)
DATABASE_URL=postgresql://app:app@db:5432/app
REDIS_URL=redis://redis:6379
SESSION_SECRET=dev-only-not-a-real-secret
Reference it in devcontainer.json with "remoteEnv":
"remoteEnv": {
"DATABASE_URL": "${localEnv:DATABASE_URL:postgresql://app:app@db:5432/app}",
"REDIS_URL": "${localEnv:REDIS_URL:redis://redis:6379}"
}
The ${localEnv:VAR:default} syntax reads from a .env file in the devcontainer folder, or falls back to the default value if no .env is present. This means the committed config works out of the box with sensible defaults, but individual engineers can override anything by creating a .env file.
Handling the real frictions
Dev containers solve the “works on my machine” problem, but they introduce new friction points. Here is how to handle the three I have hit most often.
Friction 1: Git credential forwarding
You need to authenticate with Git from inside the container. VS Code handles this automatically when you use the credential helper forwarding, but only if you configure it.
Add this to your devcontainer.json:
"mounts": [
"source=/run/host-services/ssh-auth.sock,target=/run/host-services/ssh-auth.sock,type=bind"
],
"containerEnv": {
"SSH_AUTH_SOCK": "/run/host-services/ssh-auth.sock"
}
On macOS you may need a different path. The VS Code Dev Containers extension handles most of this automatically if you accept the “forward SSH agent” prompt when the container starts.
Friction 2: File watcher limits
Node.js tooling (Vite, Webpack, Jest in watch mode) uses file watchers that hit the inotify limit on Linux hosts. Inside a container on macOS or Windows, this is even worse because of the filesystem translation layer.
Add this to the Dockerfile:
# Increase file watcher limit for dev tooling
RUN echo 'fs.inotify.max_user_watches=524288' | tee -a /etc/sysctl.conf
Some setups require --privileged mode or cap_add in docker-compose.yml to let the container set sysctl values. An alternative that avoids the permission dance is to configure your tools to use polling instead of file-system events:
// vite.config.ts or similar
export default defineConfig({
server: {
watch: {
usePolling: true,
interval: 1000,
},
},
});
Polling is slower, but it is reliable on every filesystem and does not require special capabilities.
Friction 3: Docker-in-Docker for integration tests
If your test suite spins up test containers (using testcontainers, dockest, or similar), you need Docker access from inside the dev container. The cleanest approach is to mount the host Docker socket:
"mounts": [
"source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind"
],
"remoteUser": "node",
"containerEnv": {
"DOCKER_HOST": "unix:///var/run/docker.sock"
}
This gives the container access to the host Docker daemon. Containers started by the test suite run on the host, not nested inside the dev container. It is not true isolation, but for local development it is fast and simple. If you need real Docker-in-Docker (for a CI simulation, for example), add a docker service to your compose file using the docker:dind image.
What this actually changes for a team
I have set up this pattern on four different projects. The measurable effects are consistent:
- Onboarding time drops from hours to minutes. A new engineer clones, opens, waits for the build, and has a running dev environment with data. No README-based installation ritual. No “actually, that Postgres version is too old, install 16 not 15.”
- CI failures from environment drift disappear. Every engineer develops against the same Postgres 16-alpine, the same Node.js 22-slim, the same Redis 7. If the pipeline passes locally, it passes in CI, because CI uses the same Dev Container spec.
- “Works on my machine” becomes a firingable phrase. When the environment is defined as code and shared by everyone, there is no machine-specific variance. If it does not work, the config is wrong, not the laptop.
The counterargument I hear most often is build time. The first build takes 3-5 minutes on a good internet connection. After that, Docker caches the layers and subsequent opens are under 30 seconds. Compared to the 45 minutes of manual setup a new hire goes through on a team without dev containers, the initial build is a bargain.
The one-sentence pitch to your team leader
Write this into your team chat or PR description:
Dev containers define the dev environment as code, give every engineer identical tooling with one click, and eliminate the single largest source of “but it worked locally” CI failures.
If someone pushes back on the Docker overhead, offer this compromise: put the devcontainer.json and Dockerfile in the repo and leave it optional. Engineers who prefer a native setup can ignore the .devcontainer folder entirely. The config costs nothing to maintain and saves hours the first time someone needs to reproduce a bug on an unfamiliar OS.
The complete file listing
Here is every file you need, relative to the project root:
.devcontainer/
devcontainer.json
Dockerfile
docker-compose.yml
.env (gitignored)
Four files. One JSON, one YAML, one Dockerfile, one gitignored env file. Commit the first three and the whole team gets the same environment.
A note from Yojji
The kind of infrastructure discipline this post describes (defining environments as code, eliminating manual setup drift, making onboarding a one-click operation) is exactly the operational maturity that separates teams that ship reliably from teams that spend every sprint fighting environment bugs. Yojji is an international custom software development company with offices in Europe, the US, and the UK, and their teams use Dev Containers and Docker Compose as standard practice across the Node.js, TypeScript, and cloud projects they deliver for clients.
If your team is spending more time fixing environment issues than shipping features, Yojji runs dedicated senior engineering teams that can bring this kind of reproducible setup to your project from day one. They handle the architecture, the CI/CD wiring, and the developer experience so your engineers focus on product code, not container configs.