Skip to main content

Containerization with Docker

Docker provides consistent, isolated environments for microservices. Learn to build optimized containers and orchestrate multiple services.
Learning Objectives:
  • Write production-ready Dockerfiles
  • Implement multi-stage builds
  • Optimize image size and build time
  • Use docker-compose for local development
  • Apply container security best practices

Docker Fundamentals for Microservices

┌─────────────────────────────────────────────────────────────────────────────┐
│                    WHY DOCKER FOR MICROSERVICES?                             │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│  ┌─────────────────────────────────────────────────────────────────────┐    │
│  │                    WITHOUT CONTAINERS                                │    │
│  │                                                                      │    │
│  │  Developer Machine         Staging              Production          │    │
│  │  ┌──────────────────┐     ┌──────────────┐     ┌──────────────┐    │    │
│  │  │ Node 16          │     │ Node 14      │     │ Node 18      │    │    │
│  │  │ npm 7            │     │ npm 6        │     │ npm 9        │    │    │
│  │  │ Linux Mint       │     │ Ubuntu 20.04 │     │ Amazon Linux │    │    │
│  │  └──────────────────┘     └──────────────┘     └──────────────┘    │    │
│  │                                                                      │    │
│  │  "It works on my machine!" 🤷                                        │    │
│  └─────────────────────────────────────────────────────────────────────┘    │
│                                                                              │
│  ┌─────────────────────────────────────────────────────────────────────┐    │
│  │                    WITH CONTAINERS                                   │    │
│  │                                                                      │    │
│  │  Developer Machine         Staging              Production          │    │
│  │  ┌──────────────────┐     ┌──────────────────┐ ┌──────────────────┐│    │
│  │  │ ┌──────────────┐ │     │ ┌──────────────┐ │ │ ┌──────────────┐ ││    │
│  │  │ │  Container   │ │     │ │  Container   │ │ │ │  Container   │ ││    │
│  │  │ │  Node 20     │ │     │ │  Node 20     │ │ │ │  Node 20     │ ││    │
│  │  │ │  Alpine      │ │     │ │  Alpine      │ │ │ │  Alpine      │ ││    │
│  │  │ └──────────────┘ │     │ └──────────────┘ │ │ └──────────────┘ ││    │
│  │  └──────────────────┘     └──────────────────┘ └──────────────────┘│    │
│  │                                                                      │    │
│  │  Same container everywhere! ✅                                       │    │
│  └─────────────────────────────────────────────────────────────────────┘    │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

Production-Ready Dockerfile

Basic Node.js Dockerfile

# Dockerfile - Basic
FROM node:20-alpine

WORKDIR /app

# Copy package files first (better caching)
COPY package*.json ./

# Install dependencies
RUN npm ci --only=production

# Copy application code
COPY . .

# Set environment
ENV NODE_ENV=production
ENV PORT=3000

# Create non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

# Expose port
EXPOSE 3000

# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1

# Start application
CMD ["node", "src/index.js"]

Multi-Stage Build (Optimized)

# Dockerfile - Multi-stage build
# ================================

# Stage 1: Dependencies
FROM node:20-alpine AS deps

WORKDIR /app

# Copy package files
COPY package*.json ./

# Install ALL dependencies (including devDependencies for build)
RUN npm ci

# ================================

# Stage 2: Build
FROM node:20-alpine AS builder

WORKDIR /app

# Copy dependencies from deps stage
COPY --from=deps /app/node_modules ./node_modules

# Copy source code
COPY . .

# Build the application (TypeScript, bundling, etc.)
RUN npm run build

# Prune devDependencies
RUN npm prune --production

# ================================

# Stage 3: Production
FROM node:20-alpine AS production

WORKDIR /app

# Add labels for image metadata
LABEL org.opencontainers.image.source="https://github.com/myorg/order-service"
LABEL org.opencontainers.image.description="Order Service"
LABEL org.opencontainers.image.version="1.0.0"

# Install dumb-init for proper signal handling
RUN apk add --no-cache dumb-init

# Create non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

# Copy built application
COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:appgroup /app/package.json ./

# Set environment
ENV NODE_ENV=production
ENV PORT=3000

# Switch to non-root user
USER appuser

# Expose port
EXPOSE 3000

# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
  CMD node -e "require('http').get('http://localhost:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"

# Use dumb-init as entrypoint
ENTRYPOINT ["dumb-init", "--"]

# Start application
CMD ["node", "dist/index.js"]

TypeScript Specific Dockerfile

# TypeScript Service Dockerfile
FROM node:20-alpine AS builder

WORKDIR /app

COPY package*.json ./
COPY tsconfig*.json ./

RUN npm ci

COPY src ./src

# Type checking and build
RUN npm run type-check && npm run build

# Production stage
FROM node:20-alpine

WORKDIR /app

RUN apk add --no-cache dumb-init && \
    addgroup -S app && adduser -S app -G app

COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package*.json ./

RUN npm ci --only=production && npm cache clean --force

USER app

ENV NODE_ENV=production

EXPOSE 3000

ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "dist/index.js"]

Docker Optimization

Layer Caching Strategy

# Optimize layer caching
# ================================

# Base image (rarely changes)
FROM node:20-alpine

WORKDIR /app

# 1. System dependencies (rarely change)
RUN apk add --no-cache dumb-init

# 2. Package files (change sometimes)
COPY package*.json ./

# 3. Dependencies (change when package.json changes)
RUN npm ci --only=production

# 4. Application code (changes frequently)
COPY . .

# Order matters: least changing layers first!

Reducing Image Size

# Size optimization techniques

# 1. Use slim base images
FROM node:20-alpine  # ~50MB vs node:20 ~350MB

# 2. Clean up in same layer
RUN npm ci --only=production && \
    npm cache clean --force && \
    rm -rf /tmp/*

# 3. Use .dockerignore
# .dockerignore file:
# node_modules
# npm-debug.log
# Dockerfile*
# docker-compose*
# .git
# .gitignore
# .env*
# *.md
# tests
# coverage
# .nyc_output

# 4. Multi-stage builds (shown above)

# 5. Don't install dev dependencies in production
RUN npm ci --only=production

.dockerignore

# .dockerignore
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# Git
.git
.gitignore
.gitattributes

# Docker
Dockerfile*
docker-compose*
.docker

# IDE
.idea
.vscode
*.swp
*.swo

# Test
tests
__tests__
coverage
.nyc_output
jest.config.*

# Docs
*.md
docs

# Environment files
.env*
!.env.example

# Build artifacts
dist
build

# Misc
.DS_Store
Thumbs.db

Docker Compose for Microservices

Complete Microservices Stack

# docker-compose.yml
version: '3.8'

services:
  # ===================
  # API Gateway
  # ===================
  api-gateway:
    build:
      context: ./services/api-gateway
      dockerfile: Dockerfile
    ports:
      - "8080:3000"
    environment:
      - NODE_ENV=development
      - ORDER_SERVICE_URL=http://order-service:3000
      - PAYMENT_SERVICE_URL=http://payment-service:3000
      - INVENTORY_SERVICE_URL=http://inventory-service:3000
      - JWT_SECRET=${JWT_SECRET}
      - REDIS_URL=redis://redis:6379
    depends_on:
      - order-service
      - payment-service
      - inventory-service
      - redis
    networks:
      - microservices
    healthcheck:
      test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/health"]
      interval: 30s
      timeout: 10s
      retries: 3

  # ===================
  # Order Service
  # ===================
  order-service:
    build:
      context: ./services/order-service
      dockerfile: Dockerfile
      target: development
    volumes:
      - ./services/order-service:/app
      - /app/node_modules
    environment:
      - NODE_ENV=development
      - DATABASE_URL=postgresql://postgres:postgres@order-db:5432/orders
      - KAFKA_BROKERS=kafka:9092
      - REDIS_URL=redis://redis:6379
    depends_on:
      order-db:
        condition: service_healthy
      kafka:
        condition: service_healthy
    networks:
      - microservices

  order-db:
    image: postgres:15-alpine
    environment:
      - POSTGRES_DB=orders
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
    volumes:
      - order-db-data:/var/lib/postgresql/data
      - ./services/order-service/db/init.sql:/docker-entrypoint-initdb.d/init.sql
    networks:
      - microservices
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5

  # ===================
  # Payment Service
  # ===================
  payment-service:
    build:
      context: ./services/payment-service
      dockerfile: Dockerfile
      target: development
    volumes:
      - ./services/payment-service:/app
      - /app/node_modules
    environment:
      - NODE_ENV=development
      - MONGODB_URI=mongodb://payment-db:27017/payments
      - STRIPE_API_KEY=${STRIPE_API_KEY}
      - KAFKA_BROKERS=kafka:9092
    depends_on:
      payment-db:
        condition: service_healthy
    networks:
      - microservices

  payment-db:
    image: mongo:6
    volumes:
      - payment-db-data:/data/db
    networks:
      - microservices
    healthcheck:
      test: echo 'db.runCommand("ping").ok' | mongosh localhost:27017/test --quiet
      interval: 10s
      timeout: 5s
      retries: 5

  # ===================
  # Inventory Service
  # ===================
  inventory-service:
    build:
      context: ./services/inventory-service
      dockerfile: Dockerfile
      target: development
    volumes:
      - ./services/inventory-service:/app
      - /app/node_modules
    environment:
      - NODE_ENV=development
      - DATABASE_URL=postgresql://postgres:postgres@inventory-db:5432/inventory
      - REDIS_URL=redis://redis:6379
    depends_on:
      inventory-db:
        condition: service_healthy
    networks:
      - microservices

  inventory-db:
    image: postgres:15-alpine
    environment:
      - POSTGRES_DB=inventory
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
    volumes:
      - inventory-db-data:/var/lib/postgresql/data
    networks:
      - microservices
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5

  # ===================
  # Infrastructure
  # ===================
  redis:
    image: redis:7-alpine
    command: redis-server --appendonly yes
    volumes:
      - redis-data:/data
    networks:
      - microservices
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5

  kafka:
    image: confluentinc/cp-kafka:7.5.0
    environment:
      KAFKA_NODE_ID: 1
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
      KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
      KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
      KAFKA_PROCESS_ROLES: broker,controller
      KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
      CLUSTER_ID: 'MkU3OEVBNTcwNTJENDM2Qk'
    volumes:
      - kafka-data:/var/lib/kafka/data
    networks:
      - microservices
    healthcheck:
      test: kafka-topics --bootstrap-server localhost:9092 --list
      interval: 30s
      timeout: 10s
      retries: 5

  # ===================
  # Observability
  # ===================
  prometheus:
    image: prom/prometheus:latest
    ports:
      - "9090:9090"
    volumes:
      - ./infrastructure/prometheus:/etc/prometheus
      - prometheus-data:/prometheus
    command:
      - '--config.file=/etc/prometheus/prometheus.yml'
      - '--storage.tsdb.path=/prometheus'
    networks:
      - microservices

  grafana:
    image: grafana/grafana:latest
    ports:
      - "3001:3000"
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=admin
    volumes:
      - grafana-data:/var/lib/grafana
      - ./infrastructure/grafana/provisioning:/etc/grafana/provisioning
    networks:
      - microservices

  jaeger:
    image: jaegertracing/all-in-one:latest
    ports:
      - "16686:16686"
      - "4317:4317"
    environment:
      - COLLECTOR_OTLP_ENABLED=true
    networks:
      - microservices

networks:
  microservices:
    driver: bridge

volumes:
  order-db-data:
  payment-db-data:
  inventory-db-data:
  redis-data:
  kafka-data:
  prometheus-data:
  grafana-data:

Development Dockerfile with Hot Reload

# Dockerfile with development and production targets
FROM node:20-alpine AS base

WORKDIR /app
RUN apk add --no-cache dumb-init

# Development stage
FROM base AS development

# Install all dependencies including devDependencies
COPY package*.json ./
RUN npm install

# Mount source code as volume in docker-compose
# No COPY needed - will use volume mount

ENV NODE_ENV=development

EXPOSE 3000

# Use nodemon for hot reload
CMD ["npx", "nodemon", "--watch", "src", "src/index.js"]

# Production stage
FROM base AS production

COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force

COPY . .

RUN addgroup -S app && adduser -S app -G app
USER app

ENV NODE_ENV=production
EXPOSE 3000

ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "src/index.js"]

Docker Compose Override for Development

# docker-compose.override.yml
version: '3.8'

# This file is automatically merged with docker-compose.yml
# for local development

services:
  order-service:
    build:
      target: development
    volumes:
      - ./services/order-service/src:/app/src
    command: npx nodemon --watch src src/index.js
    ports:
      - "3001:3000"
      - "9229:9229"  # Node debugger

  payment-service:
    build:
      target: development
    volumes:
      - ./services/payment-service/src:/app/src
    command: npx nodemon --watch src src/index.js
    ports:
      - "3002:3000"
      - "9230:9229"

  inventory-service:
    build:
      target: development
    volumes:
      - ./services/inventory-service/src:/app/src
    command: npx nodemon --watch src src/index.js
    ports:
      - "3003:3000"
      - "9231:9229"

Container Security

Security Best Practices

# Security-hardened Dockerfile
FROM node:20-alpine AS builder

# Update and patch base image
RUN apk update && apk upgrade

WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

COPY . .

# Production stage with security hardening
FROM node:20-alpine

# Security updates
RUN apk update && \
    apk upgrade && \
    apk add --no-cache dumb-init && \
    rm -rf /var/cache/apk/*

WORKDIR /app

# Create non-root user with specific UID/GID
RUN addgroup -S -g 1001 appgroup && \
    adduser -S -u 1001 -G appgroup appuser

# Copy with correct ownership
COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:appgroup /app .

# Remove unnecessary files
RUN rm -rf .git .gitignore .env* Dockerfile* docker-compose* && \
    chmod -R 500 /app && \
    chmod -R 400 /app/node_modules

# Set security environment variables
ENV NODE_ENV=production
ENV NPM_CONFIG_LOGLEVEL=warn

# Read-only root filesystem support
# (use with --read-only in docker run)
RUN mkdir -p /tmp && chown appuser:appgroup /tmp

# Switch to non-root user
USER appuser

# No capabilities needed
# Use with: docker run --cap-drop=ALL

EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
  CMD node healthcheck.js

ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "dist/index.js"]

Healthcheck Script

// healthcheck.js
const http = require('http');

const options = {
  hostname: 'localhost',
  port: process.env.PORT || 3000,
  path: '/health',
  method: 'GET',
  timeout: 5000
};

const req = http.request(options, (res) => {
  process.exit(res.statusCode === 200 ? 0 : 1);
});

req.on('error', () => {
  process.exit(1);
});

req.on('timeout', () => {
  req.destroy();
  process.exit(1);
});

req.end();

Security Scanning

# .github/workflows/docker-security.yml
name: Docker Security Scan

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build image
        run: docker build -t myapp:${{ github.sha }} .

      - name: Run Trivy vulnerability scanner
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: 'myapp:${{ github.sha }}'
          format: 'sarif'
          output: 'trivy-results.sarif'
          severity: 'CRITICAL,HIGH'

      - name: Upload Trivy scan results
        uses: github/codeql-action/upload-sarif@v2
        with:
          sarif_file: 'trivy-results.sarif'

      - name: Run Hadolint
        uses: hadolint/[email protected]
        with:
          dockerfile: Dockerfile

Docker Commands Reference

# Build commands
docker build -t order-service:1.0.0 .
docker build -t order-service:1.0.0 --target production .
docker build --no-cache -t order-service:1.0.0 .

# Run commands
docker run -d --name order -p 3000:3000 order-service:1.0.0
docker run -d --read-only --cap-drop=ALL -p 3000:3000 order-service:1.0.0
docker run --rm -it order-service:1.0.0 /bin/sh

# Compose commands
docker-compose up -d
docker-compose up --build
docker-compose down
docker-compose down -v  # Remove volumes
docker-compose logs -f order-service
docker-compose exec order-service /bin/sh

# Debug commands
docker logs -f order-service
docker exec -it order-service /bin/sh
docker inspect order-service
docker stats

# Cleanup
docker system prune -a
docker volume prune
docker image prune -a

Interview Questions

Answer:Multi-stage build uses multiple FROM statements to create intermediate images.Benefits:
  • Smaller final images (only runtime dependencies)
  • Separate build and runtime environments
  • Don’t expose build tools in production
  • Better security (fewer attack vectors)
Example:
FROM node:20 AS builder
RUN npm ci && npm run build

FROM node:20-alpine AS production
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
Typical reduction: 500MB → 100MB
Answer:Key principles:
  1. Order instructions by change frequency (least → most)
  2. Copy dependency files before source code
  3. Combine RUN commands to reduce layers
Example:
# Good: package.json rarely changes
COPY package*.json ./
RUN npm ci

# Then source code (changes often)
COPY . .
Tips:
  • Use .dockerignore to exclude unnecessary files
  • Pin versions to ensure consistent builds
  • Use --no-cache only when needed
Answer:
  1. Don’t run as root
    RUN adduser -S app
    USER app
    
  2. Use minimal base images
    • Alpine, distroless, slim variants
  3. Scan for vulnerabilities
    • Trivy, Snyk, Clair
  4. Drop capabilities
    docker run --cap-drop=ALL
    
  5. Read-only filesystem
    docker run --read-only
    
  6. No secrets in images
    • Use environment variables or secrets managers
  7. Keep images updated
    • Regularly rebuild with patched base images

Summary

Key Takeaways

  • Multi-stage builds for optimized images
  • Layer caching for faster builds
  • Run as non-root user
  • docker-compose for local development
  • Scan images for vulnerabilities

Next Steps

In the next chapter, we’ll deploy to Kubernetes - the industry standard for container orchestration.