Containers aren't just for production. Used correctly, they eliminate "works on my machine" bugs and give every developer an identical environment — without sacrificing the fast feedback loops you need while coding.
1. The Dev/Prod Parity Problem
"Works on my machine" is the oldest excuse in software. It happens because developer laptops drift from production:
- Different OS, different library versions, different system packages
- Node 18 on your Mac, Node 20 in CI, Node 16 on the intern's laptop
- That one native dependency compiled differently on Linux vs macOS
Containers solve this: same image, same dependencies, everywhere. But naive containerized development is painfully slow:
# The naive (slow) approach:
# 1. Edit code on host
# 2. Rebuild image (docker build ...)
# 3. Restart container (docker run ...)
# 4. Wait 30+ seconds
# 5. See your change
# 6. Repeat 😩
The rest of this lesson shows how to get container consistency without sacrificing developer speed.
2. Bind Mounts for Hot Reload
The key insight: don't bake source code into the dev image. Instead, mount it in so edits appear instantly inside the container:
# Mount local ./src into /app/src inside the container
docker run -v ./src:/app/src -p 3000:3000 my-app-dev
Combined with a file-watching tool, you get instant feedback:
| Language | Watch Tool | What It Does |
|---|---|---|
| Node.js | nodemon | Restarts server on file change |
| Node.js | webpack-dev-server / vite | Hot Module Replacement (HMR) |
| Go | air | Live-reload for Go apps |
| Python | uvicorn --reload | Auto-restart on change |
| Rust | cargo-watch | Re-compile on save |
# Dockerfile.dev — optimized for dev, not prod
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
# No COPY of source — it comes via bind mount
EXPOSE 3000
CMD ["npx", "nodemon", "src/index.js"]
Many teams keep Dockerfile for production (multi-stage, minimal) and Dockerfile.dev for development (dev deps included, watch tools, debug ports). Alternatively, use multi-stage with a dev target: docker build --target dev .
3. Dev Containers (VS Code)
Dev Containers take the idea further: your entire development environment IS a container. VS Code (or any compatible editor) runs inside it — extensions, tools, and settings travel with the project.
// .devcontainer/devcontainer.json
{
"name": "My Project Dev",
"image": "mcr.microsoft.com/devcontainers/javascript-node:20",
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
},
"forwardPorts": [3000, 5432],
"postCreateCommand": "npm install",
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"ms-azuretools.vscode-docker"
],
"settings": {
"editor.formatOnSave": true
}
}
},
"mounts": [
"source=${localWorkspaceFolder},target=/workspace,type=bind"
]
}
Why Dev Containers?
- Team consistency — new hire opens project, gets the exact same environment
- No global installs — Node, Python, Go versions live in the container, not on host
- Reproducible — commit
.devcontainer/to git; the environment is versioned - Isolated — project A needs Node 18, project B needs Node 20 — no conflict
4. Docker Compose for Dev
Real apps need more than one container (app + database + cache + …). Docker Compose shines for local dev with its compose.override.yaml pattern:
# compose.yaml — base (shared between dev and prod)
services:
app:
build: .
ports:
- "3000:3000"
depends_on:
- db
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: secret
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
# compose.override.yaml — dev overrides (auto-loaded!)
services:
app:
build:
context: .
dockerfile: Dockerfile.dev
volumes:
- ./src:/app/src # bind mount for hot reload
- /app/node_modules # anonymous volume — keep container's node_modules
environment:
- NODE_ENV=development
- DEBUG=app:*
ports:
- "9229:9229" # Node.js debug port
db:
ports:
- "5432:5432" # Expose DB for local tools
Run docker compose up — it automatically merges compose.yaml + compose.override.yaml. For production, use docker compose -f compose.yaml -f compose.prod.yaml up.
Notice /app/node_modules as a volume with no host path. This prevents the bind mount from overwriting the container's node_modules (which were installed during build). The container keeps its own copy — essential for native modules compiled for Linux.
5. Debugging Inside Containers
Remote debugging connects your IDE's debugger to the process running inside the container:
| Runtime | Debug Port | Start Command |
|---|---|---|
| Node.js | 9229 | node --inspect=0.0.0.0:9229 app.js |
| Java | 5005 | -agentlib:jdwp=transport=dt_socket,server=y,address=*:5005 |
| Python | 5678 | python -m debugpy --listen 0.0.0.0:5678 app.py |
| Go (Delve) | 2345 | dlv debug --headless --listen=:2345 |
# VS Code launch.json for Node.js in container
{
"type": "node",
"request": "attach",
"name": "Docker: Attach",
"port": 9229,
"address": "localhost",
"localRoot": "${workspaceFolder}/src",
"remoteRoot": "/app/src"
}
For ad-hoc investigation, docker exec is your friend:
# Get a shell inside a running container
docker exec -it my-app sh
# Check environment variables
docker exec my-app env
# Tail logs in real-time
docker exec my-app tail -f /var/log/app.log
6. Performance Gotchas
Bind mounts have a dirty secret: they're slow on macOS and Windows. Docker Desktop runs Linux in a VM, and file system operations cross the VM boundary:
| Platform | Bind Mount Speed | Why |
|---|---|---|
| Linux | Native speed | No VM — direct kernel access |
| macOS | 2–10× slower | File events cross VM boundary (hypervisor framework) |
| Windows (WSL2) | Near-native* | *Only if files live inside WSL2 filesystem, not /mnt/c |
Solutions
- Named volumes for heavy dirs — keep
node_modules,vendor,.venvin a Docker volume (not bind-mounted) - VirtioFS — Docker Desktop's newer file sharing backend (default on macOS since Docker Desktop 4.15+); significantly faster than gRPC-FUSE
- Mutagen — real-time two-way file sync; avoids bind mounts entirely
- Keep source in WSL2 — on Windows, clone repos inside the WSL2 filesystem (
~/projects/), not on Windows drives
You may see :cached or :delegated mount flags in older tutorials. These were macOS-specific hints that are now ignored by Docker Desktop (VirtioFS handles consistency automatically). Don't rely on them.
7. When NOT to Use Containers for Dev
Containers aren't always the answer. Skip them when:
- Simple scripts — a standalone Python script or Bash tool doesn't need containerized dev
- Early prototyping — exploring an idea? The setup overhead slows you down
- Tight hardware access — GPU development, embedded systems, Bluetooth/USB devices
- The team already has parity — if everyone runs the same OS + versions and CI matches, the benefit is marginal
- Friction outweighs benefit — if you spend more time debugging Docker than writing code, step back
Use containers where they reduce friction — multi-service apps, complex dependency chains, onboarding new developers. Don't use them to prove a point.
Dev Container Workflow
🧠 Quiz 1: Bind Mount Performance
Why are bind mounts slower on macOS than on Linux?
🧠 Quiz 2: Dev Containers
What is the PRIMARY benefit of committing a .devcontainer/ folder to your repository?
🧠 Quiz 3: When to Skip Containers
Which scenario is the BEST candidate for skipping containerized development?
🛠️ Hands-On Tasks
Set up a Node.js Express app with hot-reload using Docker Compose:
# 1. Create project structure
mkdir hot-reload-demo && cd hot-reload-demo
mkdir src
# 2. Create src/index.js
cat > src/index.js << 'EOF'
const express = require('express');
const app = express();
app.get('/', (req, res) => res.json({ message: 'Hello from container!' }));
app.listen(3000, () => console.log('Server on :3000'));
EOF
# 3. Create package.json
cat > package.json << 'EOF'
{
"name": "hot-reload-demo",
"dependencies": { "express": "^4.18.0" },
"devDependencies": { "nodemon": "^3.0.0" }
}
EOF
# 4. Create Dockerfile.dev
cat > Dockerfile.dev << 'EOF'
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
EXPOSE 3000
CMD ["npx", "nodemon", "--watch", "src", "src/index.js"]
EOF
# 5. Create compose.yaml
cat > compose.yaml << 'EOF'
services:
app:
build:
context: .
dockerfile: Dockerfile.dev
ports:
- "3000:3000"
volumes:
- ./src:/app/src
- /app/node_modules
EOF
# 6. Run it
docker compose up --build
# 7. Edit src/index.js — change the message
# Watch the container restart automatically!
# 8. Visit http://localhost:3000 to see the change
Add a dev container to any existing project:
# 1. Create the devcontainer folder
mkdir -p .devcontainer
# 2. Create .devcontainer/devcontainer.json
cat > .devcontainer/devcontainer.json << 'EOF'
{
"name": "My Project",
"image": "mcr.microsoft.com/devcontainers/javascript-node:20",
"forwardPorts": [3000],
"postCreateCommand": "npm install",
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
],
"settings": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}
}
}
EOF
# 3. Open in VS Code and use:
# Cmd/Ctrl+Shift+P → "Dev Containers: Reopen in Container"
# 4. Verify: open a terminal in VS Code
node --version # Should show v20.x
npm --version # Installed in the container
# 5. Try adding a Dockerfile-based config instead:
cat > .devcontainer/Dockerfile << 'EOF'
FROM node:20-alpine
RUN apk add --no-cache git curl
RUN npm install -g typescript eslint
EOF
# Update devcontainer.json to use it:
# "build": { "dockerfile": "Dockerfile" } (instead of "image")
GitHub Codespaces and Gitpod take dev containers to the cloud. They read your .devcontainer/ config, spin up a powerful cloud VM, build the container, and give you a full VS Code experience in your browser — or connect your local VS Code via SSH.
- Codespaces — deep GitHub integration; prebuilds cache the container so it starts in seconds
- Gitpod — works with GitHub, GitLab, Bitbucket; uses
.gitpod.yml(similar concept) - Why? — onboard in minutes, not hours. No "clone → install → configure" dance. Especially powerful for open-source contributions.
The dev container spec is now an open standard — not locked to any single tool.
Key Takeaways
- Bind mounts + watch tools give you hot reload inside containers — the best of both worlds
- Dev containers version your entire dev environment alongside your code
- compose.override.yaml separates dev concerns (debug ports, volumes) from the base config
- Performance matters — use named volumes for dependency dirs; enable VirtioFS on macOS
- Debug remotely — expose debug ports and attach your IDE's debugger to the container
- Be pragmatic — containers for dev are a tool, not a religion. Skip them when they add more friction than value.