💻 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.jsonwith 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.
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".
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:
| Hook | Runs | Typical use |
|---|---|---|
onCreateCommand | Once, while the image is built | Heavy one-time setup |
postCreateCommand | Once, after the container is created | npm install, pip install |
postStartCommand | Every time the container starts | Start 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
.devcontainerfolder so the environment is versioned with the code. - Prefer Features over hand-rolled Dockerfile install steps for common tools.
- Put installs in
postCreateCommand, notpostStartCommand. - Use a named volume for
node_modulesto 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.envor Codespaces secrets. - Don't pin a floating base like
:latest; name a concrete version. - Don't bind-mount
node_modulesfrom 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:
- Create a folder with a minimal Express app and run
npm init -y && npm install express. - Add
.devcontainer/devcontainer.jsonusing thejavascript-node:20image. - Forward port 3000, add the ESLint + Prettier extensions, and set
postCreateCommandtonpm install. - Open the folder in VS Code and choose Reopen in Container.
- In the container terminal, run your app and confirm
localhost:3000works from your host browser. - Delete your local
node_modules, rebuild the container, and watchpostCreateCommandrestore 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.jsondeclares 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
- VS Code — Developing inside a Container
- The Dev Container Specification
- Dev Container Features catalog
🚀 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.