DevOps
Overview
Everyone knows Docker is great for production. But most local Docker setups are sluggish. Here's how we configure fast, reproducible dev environments.
Luca Ferretti
Docker promises consistency: "It works on my machine" becomes irrelevant when everyone's machine runs the same container.
But local Docker often feels like molasses. File changes take seconds to sync. Hot reload is a myth. And your laptop fan sounds like a jet engine.
Docker architecture diagram
# The default (slow) way
volumes:
- .:/app # Every file change syncs across the Docker boundary
Each file change triggers notifications to the container. For Node.js projects with node_modules (thousands of files), this is death by a thousand cuts.
version: '3.8'
services:
app:
volumes:
# Use delegated mode for better performance
- .:/app:delegated
# Or use named volumes for node_modules
- /app/node_modules
- ./:/app:cached
The :delegated flag tells Docker: "The container's view may lag behind the host." For development, this is perfectly fine and 10x faster.
# Stage 1: Dependencies (cached heavily)
FROM node:20-alpine AS deps
WORKDIR /app
# Copy only dependency files
COPY package*.json ./
COPY prisma ./prisma/
# Install with caching
RUN npm ci --only=production && npm cache clean --force
# Stage 2: Builder
FROM node:20-alpine AS builder
WORKDIR /app
# Copy deps and source
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build the app
RUN npm run build
# Stage 3: Development (different config!)
FROM node:20-alpine AS development
WORKDIR /app
# Install dev dependencies
COPY package*.json ./
RUN npm install
# Copy source (will be overridden by volume mount)
COPY . .
# Development command
CMD ["npm", "run", "dev"]
# Stage 4: Production (optimized)
FROM node:20-alpine AS production
WORKDIR /app
# Copy only what's needed
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package*.json ./
USER node
CMD ["npm", "start"]
Multi-stage build visualization
# docker-compose.yml
version: '3.8'
services:
app:
build:
context: .
target: development # Use development stage
ports:
- "3000:3000"
environment:
- NODE_ENV=development
- DATABASE_URL=postgresql://postgres:postgres@db:5432/app
volumes:
# Mount source code for hot reload
- .:/app:delegated
# Persist node_modules (don't sync from host)
- /app/node_modules
- /app/.next
depends_on:
- db
- redis
command: npm run dev
db:
image: postgres:15-alpine
ports:
- "5432:5432"
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=app
volumes:
# Persist database data
- postgres_data:/var/lib/postgresql/data
# Seed scripts
- ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
command: redis-server --appendonly yes
volumes:
postgres_data:
redis_data:
// next.config.js
module.exports = {
webpackDevMiddleware: config => {
// Enable polling for Docker (optional, usually not needed with delegated mounts)
config.watchOptions = {
poll: 1000,
aggregateTimeout: 300,
}
return config
}
}
# Use watchdog for file system events
RUN pip install watchdog
CMD ["watchmedo", "auto-restart", "--patterns=*.py", "--recursive", "--", "python", "app.py"]
# Install air for live reload
RUN go install github.com/cosmtrek/air@latest
CMD ["air", "-c", ".air.toml"]
#!/bin/bash
# scripts/docker-dev-init.sh
# Build the development image
docker compose build
# Install dependencies (if needed)
docker compose run --rm app npm install
# Setup database
docker compose run --rm app npx prisma migrate dev
# Seed data
docker compose run --rm app npm run seed
# Start everything
docker compose up
# Add a debug service to docker-compose.yml
debug:
build:
context: .
target: development
command: sleep infinity
volumes:
- .:/app:delegated
profiles:
- debug
Use it:
docker compose --profile debug run debug bash
# Now you're inside the container with all dev tools
Bind mount (default):
Optimized mount (:delegated):
Named volumes + caching:
# Add to docker-compose.yml
services:
app:
platform: linux/amd64 # Rosetta 2 emulation
volumes:
- .:/app:delegated # Essential on macOS
- ~/.npm:/root/.npm # Cache npm across containers
Also: Enable "gRPC FUSE" and "VirtioFS" in Docker Desktop settings.
services:
app:
volumes:
# Use WSL2 integration
- .:/app:cached
# Store node_modules inside WSL (not Windows filesystem)
- /app/node_modules
Keep your code in the WSL filesystem (\wsl$), not Windows (C:).
Already fast! Use :cached for consistency:
volumes:
- .:/app:cached
# docker-compose.yml
services:
app:
# ... config
# Development-only services
mailhog:
image: mailhog/mailhog
profiles: ["dev", "full"]
adminer:
image: adminer
profiles: ["dev", "full"]
# Production-only services
nginx:
image: nginx
profiles: ["prod"]
# Start dev with minimal services
# docker compose --profile dev up
# Start dev with all helpers
# docker compose --profile full up
# Fix: Use anonymous volume for node_modules
volumes:
- .:/app:delegated
- /app/node_modules # No colon means anonymous volume
# Fix: Match host user ID
ARG USER_ID=1000
ARG GROUP_ID=1000
RUN addgroup -g $GROUP_ID appgroup && adduser -u $USER_ID -G appgroup -D appuser
USER appuser
# Fix: Cache npm globally
VOLUME /root/.npm
# And in compose:
volumes:
- npm_cache:/root/.npm
volumes:
npm_cache:
Run this inside your container:
time (echo "test" > test.txt && rm test.txt)
If it takes >0.1 seconds → Your volume mount is slow. Use :delegated or :cached.
If <0.05 seconds → You're good.
Docker for development should feel almost native. If it doesn't, you're doing it wrong. These patterns have turned Docker from "necessary evil" to "productivity superpower" for my teams.
The goal: "docker compose up" and you're coding in under 30 seconds. That's the benchmark. Hit it.
Written by
Luca Ferretti
Let's work together