Skip to main content

💻 Development Containers in VS Code

"It works on my machine" is a bug in your setup, not your code. Dev Containers move your entire toolchain — language runtime, extensions, settings, even the database — into a container defined in your repo, so every teammate opens the project into the exact same environment.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain how a dev container differs from just running your app in Docker
  • Author a devcontainer.json with an image, Features, extensions, and settings
  • Use the lifecycle commands (postCreateCommand, postStartCommand) correctly
  • Back a dev container with Docker Compose for multi-service projects
  • Reuse the same config in the cloud with GitHub Codespaces

Estimated Time: 30–40 minutes  •  Difficulty: Intermediate

Hands-on: Add a dev container to a Node project and reopen it in the container.

In This Lesson

What Is a Dev Container?

You already know how to run an app in a container. A development container flips the idea around: instead of packaging the finished app, it packages the place you build the app. VS Code launches a container, mounts your source into it, installs your extensions inside it, and then connects your editor to it. You edit locally; everything actually runs in the container.

💡 A useful analogy: A dev container is a fully-equipped workshop that travels with the project. Whoever opens the repo walks into the same workshop — same tools, same bench, same versions — so nobody wastes a day discovering their Node is three majors behind.
Local VS Code connected to a dev container The VS Code UI runs on the host and connects to a container that holds the language runtime, extensions, and terminal, with the project source bind-mounted in. Your machine VS Code UI (window) project files on disk Dev container language runtime extensions terminal & tools source (bind-mounted) connect edits sync
Figure 1 — The editor UI stays on your machine; the runtime, extensions, and terminal live in the container. Your source is bind-mounted so edits are instant on both sides.

Prerequisites & Setup

Three pieces get you started:

  • VS Code — the editor.
  • Docker Desktop (or Docker Engine on Linux) — the container runtime.
  • Dev Containers extension — search "Dev Containers" in the Extensions view (Ctrl+Shift+X) and install the Microsoft one.

Confirm Docker is alive before you begin:

docker --version
docker run --rm hello-world

Once installed, a small green remote indicator appears in the bottom-left corner of VS Code. Opening a folder that contains a .devcontainer config will prompt "Reopen in Container".

flowchart LR A[Install VS Code] --> B[Install Docker] B --> C[Add Dev Containers extension] C --> D[Add .devcontainer config] D --> E[Reopen in Container] E --> F[Start coding]

Anatomy of devcontainer.json

The config lives at .devcontainer/devcontainer.json. At minimum it names an image; from there you layer on ports, extensions, and settings. Here's a complete Node example:

{
  "name": "Node.js Development",
  "image": "mcr.microsoft.com/devcontainers/javascript-node:20",
  "forwardPorts": [3000],
  "customizations": {
    "vscode": {
      "extensions": [
        "dbaeumer.vscode-eslint",
        "esbenp.prettier-vscode"
      ],
      "settings": {
        "editor.formatOnSave": true,
        "editor.defaultFormatter": "esbenp.prettier-vscode"
      }
    }
  },
  "postCreateCommand": "npm install",
  "remoteUser": "node"
}

📖 What each key does

image / build — start from a prebuilt image, or build a custom Dockerfile.

forwardPorts — expose container ports to your host so localhost:3000 reaches the app.

customizations.vscode — extensions and settings installed inside the container, committed to the repo.

remoteUser — the non-root user VS Code runs as inside the container.

Features & Lifecycle Commands

Features — tools without writing a Dockerfile

Features are reusable, community-maintained install scripts you drop into your config. Need the GitHub CLI, Docker-in-Docker, or an extra language? Add a line instead of hand-writing apt-get in a Dockerfile:

{
  "image": "mcr.microsoft.com/devcontainers/base:ubuntu",
  "features": {
    "ghcr.io/devcontainers/features/node:1": { "version": "20" },
    "ghcr.io/devcontainers/features/python:1": { "version": "3.12" },
    "ghcr.io/devcontainers/features/github-cli:1": {},
    "ghcr.io/devcontainers/features/docker-in-docker:1": {}
  }
}

Lifecycle commands — run the right thing at the right time

These hooks fire at distinct moments. The one you'll use most is postCreateCommand for installing dependencies:

HookRunsTypical use
onCreateCommandOnce, while the image is builtHeavy one-time setup
postCreateCommandOnce, after the container is creatednpm install, pip install
postStartCommandEvery time the container startsStart a watcher or service

⚠️ Don't put dependency installs in postStartCommand

postStartCommand runs on every start, so putting npm install there re-installs every time you reopen the project. Installs belong in postCreateCommand, which runs once.

Multi-Service with Compose

When your project needs a database alongside the app, point the dev container at a Docker Compose file and tell VS Code which service to attach to. VS Code develops inside the app service while the db service runs beside it.

.devcontainer/devcontainer.json:

{
  "name": "Web App with Database",
  "dockerComposeFile": "compose.yaml",
  "service": "app",
  "workspaceFolder": "/workspace",
  "forwardPorts": [3000, 5432],
  "customizations": {
    "vscode": {
      "extensions": ["dbaeumer.vscode-eslint", "mtxr.sqltools-driver-pg"]
    }
  },
  "postCreateCommand": "npm install"
}

.devcontainer/compose.yaml:

services:
  app:
    build:
      context: ..
      dockerfile: .devcontainer/Dockerfile
    volumes:
      - ..:/workspace:cached      # your source, live-mounted
    command: sleep infinity       # keep the container alive for VS Code
    environment:
      DATABASE_URL: postgres://postgres:postgres@db:5432/myapp
    depends_on:
      - db

  db:
    image: postgres:16
    restart: unless-stopped
    volumes:
      - postgres-data:/var/lib/postgresql/data
    environment:
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: myapp

volumes:
  postgres-data:

💡 Why command: sleep infinity?

The app service has no long-running process of its own — VS Code is the thing using it. sleep infinity keeps the container running so the editor can attach, open terminals, and run your app on demand. The app reaches Postgres by the service name db, exactly as in the Compose lesson.

GitHub Codespaces

The best part: the same .devcontainer config powers GitHub Codespaces, a full VS Code environment running in the cloud. A contributor with nothing but a browser clicks "Create codespace" and lands in your exact environment — no local Docker, no setup.

You can add cloud-specific hints without breaking local use:

{
  "name": "My Project",
  "image": "mcr.microsoft.com/devcontainers/universal:2",
  "hostRequirements": { "cpus": 4, "memory": "8gb" },
  "customizations": {
    "codespaces": {
      "openFiles": ["README.md", "src/app.js"]
    }
  }
}

✅ Write once, run local or cloud

Because Codespaces and local Dev Containers share the spec, a single config file gives you both. New contributors onboard in minutes from any device.

Best Practices (Do / Don't)

✅ Do

  • Commit the .devcontainer folder so the environment is versioned with the code.
  • Prefer Features over hand-rolled Dockerfile install steps for common tools.
  • Put installs in postCreateCommand, not postStartCommand.
  • Use a named volume for node_modules to avoid slow bind-mount I/O on macOS/Windows.
  • Run as a non-root remoteUser.

⚠️ Don't

  • Don't hardcode secrets in devcontainer.json — use .env or Codespaces secrets.
  • Don't pin a floating base like :latest; name a concrete version.
  • Don't bind-mount node_modules from the host — it fights the container's own install.
  • Don't forget to rebuild (Dev Containers: Rebuild Container) after changing the config.

Hands-on Exercise

🏋️ Add a dev container to a Node project

Objective: Turn a bare Node folder into a one-click, reproducible workspace.

Instructions:

  1. Create a folder with a minimal Express app and run npm init -y && npm install express.
  2. Add .devcontainer/devcontainer.json using the javascript-node:20 image.
  3. Forward port 3000, add the ESLint + Prettier extensions, and set postCreateCommand to npm install.
  4. Open the folder in VS Code and choose Reopen in Container.
  5. In the container terminal, run your app and confirm localhost:3000 works from your host browser.
  6. Delete your local node_modules, rebuild the container, and watch postCreateCommand restore it.
💡 Hint

If the browser can't reach the app, check that the port is in forwardPorts and that your server listens on 0.0.0.0 (not just 127.0.0.1) so it's reachable from outside the container.

✅ Solution
{
  "name": "Express Dev",
  "image": "mcr.microsoft.com/devcontainers/javascript-node:20",
  "forwardPorts": [3000],
  "customizations": {
    "vscode": {
      "extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"],
      "settings": { "editor.formatOnSave": true }
    }
  },
  "postCreateCommand": "npm install",
  "remoteUser": "node"
}

Reopening builds the container, installs your extensions inside it, forwards port 3000, and runs npm install once. Anyone who clones the repo gets the identical setup.

🎯 Quick Quiz

Question 1: What runs where when you use a dev container?

Question 2: Where should npm install go so it runs only once per container creation?

Question 3: What's the advantage of Features in devcontainer.json?

Summary & Quiz

🎉 Key Takeaways

  • A dev container packages your development environment; VS Code runs its UI locally and connects into the container.
  • devcontainer.json declares the image, forwarded ports, extensions, and settings — versioned in the repo.
  • Features add common tools with one line; lifecycle commands run setup at the right moment.
  • Point at a Compose file to develop against a database and other services.
  • The same config powers GitHub Codespaces — onboard from a browser in minutes.

📚 Further Reading

🚀 What's Next?

You've now built the whole Module 2 toolkit — Git, Docker, Compose, and reproducible dev environments. Next you'll put it all together in a Weekend Project combining version control and containerization end to end.

🎉 Nice work!

"Works on my machine" is officially behind you. Time to ship a real project with these tools.