Docker Dev Environments That Don't Slow You Down
Published
12 min read
Feb 22, 2025

DevOps

Docker Dev Environments That Don't Slow You Down

DockerDevOpsDXPerformance

Overview

Everyone knows Docker is great for production. But most local Docker setups are sluggish. Here's how we configure fast, reproducible dev environments.

DockerDevOpsDXPerformance
Luca Ferretti

Luca Ferretti

Author
12 min read
Reading Time

Docker Dev Environments That Don't Slow You Down

The Local Docker Paradox

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 diagramDocker architecture diagram

Why Local Docker Is Slow (And How to Fix It)

The Bind Mount Problem

yaml
# 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.

The Solution: Optimized Volume Mounts

yaml
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.

The Dockerfile That Doesn't Suck

Multi-stage for Fast Builds

dockerfile
# 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 visualizationMulti-stage build visualization

The Docker Compose Setup That Actually Works

Development-First Configuration

yaml
# 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:

Hot Reload That Actually Works

For Node.js/Next.js

javascript
// 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
  }
}

For Python (Django/Flask)

dockerfile
# Use watchdog for file system events
RUN pip install watchdog

CMD ["watchmedo", "auto-restart", "--patterns=*.py", "--recursive", "--", "python", "app.py"]

For Go (with air)

dockerfile
# Install air for live reload
RUN go install github.com/cosmtrek/air@latest

CMD ["air", "-c", ".air.toml"]

Development Utilities That Help

The Init Script

bash
#!/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

The Debug Container

yaml
# Add a debug service to docker-compose.yml
debug:
  build:
    context: .
    target: development
  command: sleep infinity
  volumes:
    - .:/app:delegated
  profiles:
    - debug

Use it:

bash
docker compose --profile debug run debug bash
# Now you're inside the container with all dev tools

Performance Comparisons (Real Numbers)

Bind mount (default):

  • File change to hot reload: 3-5 seconds
  • npm install: 45 seconds
  • Build time: 35 seconds

Optimized mount (:delegated):

  • File change to hot reload: 0.5 seconds
  • npm install: 12 seconds (with layer caching)
  • Build time: 8 seconds (with layer caching)

Named volumes + caching:

  • File change to hot reload: 0.3 seconds
  • npm install: 8 seconds
  • Build time: 5 seconds

Platform-Specific Optimizations

macOS

yaml
# 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.

Windows (WSL2)

yaml
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:).

Linux

Already fast! Use :cached for consistency:

yaml
volumes:
  - .:/app:cached

The Ultimate Performance Hack: Docker Compose Profiles

yaml
# 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

Common Pitfalls Fixed

Problem: "node_modules" disappears

yaml
# Fix: Use anonymous volume for node_modules
volumes:
  - .:/app:delegated
  - /app/node_modules  # No colon means anonymous volume

Problem: File permissions errors

dockerfile
# 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

Problem: Slow npm install every time

dockerfile
# Fix: Cache npm globally
VOLUME /root/.npm

# And in compose:
volumes:
  - npm_cache:/root/.npm

volumes:
  npm_cache:

The Simple Test: Is Your Setup Fast?

Run this inside your container:

bash
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.

Luca Ferretti

Written by

Luca Ferretti

Let's work together

Start your project.