DockerDevOpsDX

Docker for Development: A Practical Workflow

10 min read
Docker eliminates 'works on my machine' problems by providing consistent, reproducible environments. But a naive Docker setup can be painfully slow for development — long build times, no hot reload, and large images. This guide shows how to configure Docker for a productive development workflow that's fast to iterate with and produces optimized production images.

Multi-Stage Builds

Multi-stage builds use multiple FROM statements in a single Dockerfile. Each stage can use a different base image and copy artifacts from previous stages. This pattern lets you use a full development image with build tools for compilation, then copy only the compiled output to a minimal production image. The result is smaller, more secure production images without sacrificing build capabilities.

Dockerfile
# Stage 1: Build
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: Production
FROM node:22-alpine AS production
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json ./
EXPOSE 3000
CMD ["node", "dist/server.js"]

Optimizing Layer Caching

Docker caches each layer and reuses it if the instruction and context haven't changed. Order your Dockerfile instructions from least-frequently-changed to most-frequently-changed. Copy package.json and lockfiles first, install dependencies, then copy application code. This way, the expensive dependency installation layer is cached as long as dependencies haven't changed, even when application code changes on every commit.

tip

Use a .dockerignore file to exclude node_modules, .git, dist, and other directories from the build context. A smaller build context means faster builds and fewer cache invalidations.

Docker Compose for Development

Docker Compose orchestrates multi-container development environments. Mount your source code as a volume for hot reload, expose debugging ports, and define dependent services (databases, caches, message brokers) alongside your application. Use a separate compose file for development that overrides production settings with development-friendly configuration.

docker-compose.dev.yml
services:
  app:
    build:
      context: .
      target: builder
    volumes:
      - .:/app
      - /app/node_modules
    ports:
      - "3000:3000"
      - "9229:9229"  # Node.js debug port
    command: npm run dev
    environment:
      - NODE_ENV=development

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: myapp_dev
      POSTGRES_PASSWORD: devpassword
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

Image Security

Use minimal base images (Alpine or distroless) to reduce the attack surface. Run containers as non-root users. Scan images for known vulnerabilities using tools like Trivy, Snyk, or Docker Scout. Pin base image versions to specific digests rather than mutable tags to ensure reproducible builds. Regularly rebuild and redeploy images to pick up security patches in base images and dependencies.

  • Use Alpine or distroless base images for production
  • Add a non-root USER instruction to your Dockerfile
  • Scan images in CI with Trivy or Docker Scout
  • Pin base images to SHA digests for reproducibility