惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

B
Blog RSS Feed
B
Blog
N
Netflix TechBlog - Medium
量子位
月光博客
月光博客
博客园_首页
博客园 - Franky
酷 壳 – CoolShell
酷 壳 – CoolShell
Last Week in AI
Last Week in AI
T
The Blog of Author Tim Ferriss
Hugging Face - Blog
Hugging Face - Blog
雷峰网
雷峰网
M
MIT News - Artificial intelligence
J
Java Code Geeks
大猫的无限游戏
大猫的无限游戏
D
DataBreaches.Net
腾讯CDC
Engineering at Meta
Engineering at Meta
云风的 BLOG
云风的 BLOG
L
LangChain Blog
GbyAI
GbyAI
IT之家
IT之家
Y
Y Combinator Blog
人人都是产品经理
人人都是产品经理

The Practical Developer

The Libuv Thread Pool Trap: Why Node.js Async APIs Stall Under Load Postgres Covering Indexes with INCLUDE: Eliminate Heap Fetches on Read-Heavy Workloads Postgres DISTINCT ON: The Fastest Way to Get the Latest Row Per Group Postgres Transaction Isolation: The Anomalies Your App Actually Faces in Production Linux TCP Tuning for Node.js Microservices: The Kernel Settings That Stop Silent Connection Drops Under Load Postgres HOT Updates and Fillfactor: Why Not All Writes Are Created Equal Database Connection Pool Leaks: Finding the Promise That Never Returns Its Seat Linux OOM Killer in Production: Why Your Node.js Containers Die Without a Stack Trace Postgres Materialized Views: Refresh Strategies That Do Not Lock Your Dashboards API Dependency Health Checks: Why /health Is Not Enough Authorization with Zanzibar Tuples: How Google Manages Permissions and How To Build the Same Check in Node.js Postgres Advisory Locks: The 20-Character Primitive That Replaces Redis for Coordination Dead Letter Queues: The Message Queue Pattern That Saves You at 2 a.m. File Descriptor Exhaustion: The Kernel Limit That Silently Drops Node.js Connections Graceful Degradation: The Pattern That Turns Total Outages into Partial Success PostgreSQL Full-Text Search: Dropping Elasticsearch for 90% of Use Cases S3 Presigned Multipart Uploads: Stop Your API Server from Being a File Upload Bottleneck MessagePack vs JSON: The Binary Serialization Switch That Cut Our Internal RPC Overhead by 40% DNS Caching in Node.js: The Silent Cause of Production Latency Spikes Reliable Cron Jobs: The Pattern That Stops Double Runs, Missed Executions, And The 2 AM Page GraphQL Query Complexity: Stop the OOM Query Before It Reaches Your Resolver Node.js Event Loop Lag: The Hidden Metric Behind Random Latency Spikes API Request Validation with Zod: The Schema That Catches Bad Input Before It Corrupts Your Database Load Shedding in Node.js: How to Reject Traffic Before You Drown Request Hedging: Cut Tail Latency In Half Without Overprovisioning Git Bisect: The Automated Binary Search That Finds Breaking Commits in Minutes Node.js Garbage Collection Tuning: Stop Letting V8 Pause Your Event Loop Node.js Server Timeouts: The Settings That Stop Slow Clients from Holding Sockets Hostage Postgres BRIN Indexes: The Time-Series Secret That Shrinks Indexes by 99% Event Sourcing with PostgreSQL: The Pragmatic 80% Solution
Dev Containers: Reproducible Node.js Development Environm...
The Practica · 2026-06-26 · via The Practical Developer

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:

  • dockerComposeFile and service. Tells VS Code to use the Docker Compose setup and attach to the app service. 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 the node user, 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 open localhost:3000 in 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.