Back to Containers & Runtime Environments Mastery Series

Part 18: Docker Compose Mastery

May 14, 2026 Wasil Zafar 30 min read

Real applications aren't single containers — they're ecosystems of services communicating over networks, sharing data through volumes, and coordinating startup sequences. Docker Compose lets you declare your entire multi-container application in a single YAML file and bring it to life with one command. This article takes you from first principles to production-ready Compose files with healthchecks, profiles, watch mode, and complete project patterns.

Table of Contents

  1. Why Compose?
  2. Compose File Versions
  3. Core Concepts
  4. Service Definition Deep Dive
  5. Build Configuration
  6. Networking in Compose
  7. Volumes in Compose
  8. Environment Variables
  9. Healthchecks & Dependencies
  10. Compose CLI Commands
  11. Project: Flask + Redis + PostgreSQL
  12. Project: Nginx + App + DB
  13. Profiles & Environments
  14. Compose Watch
  15. Exercises
  16. Conclusion & Next Steps

Why Compose?

Consider a typical web application: a frontend, an API server, a database, a cache, a message queue, and perhaps a background worker. Running each component as a separate docker run command with the correct network, volume, and environment configuration is tedious and error-prone. Docker Compose solves this by defining your entire application stack declaratively in a single YAML file.

The Multi-Container Reality

Without Compose, deploying a three-service application requires multiple commands with careful coordination:

# Without Compose: manual multi-container deployment
# Step 1: Create a shared network
docker network create myapp-net

# Step 2: Start the database with volume persistence
docker run -d \
  --name postgres \
  --network myapp-net \
  -v pgdata:/var/lib/postgresql/data \
  -e POSTGRES_PASSWORD=secret \
  -e POSTGRES_DB=myapp \
  postgres:16

# Step 3: Start Redis cache
docker run -d \
  --name redis \
  --network myapp-net \
  redis:7-alpine

# Step 4: Start the application (depends on both services being ready)
docker run -d \
  --name webapp \
  --network myapp-net \
  -p 8000:8000 \
  -e DATABASE_URL=postgres://postgres:secret@postgres:5432/myapp \
  -e REDIS_URL=redis://redis:6379 \
  myapp:latest

# Cleanup requires stopping and removing each container + network + volumes
docker stop webapp redis postgres
docker rm webapp redis postgres
docker network rm myapp-net

With Compose, this entire stack is defined once and managed with a single command:

# With Compose: one command does everything
docker compose up -d      # Start all services
docker compose down       # Stop and clean up everything
docker compose logs -f    # Stream all logs together
Key Insight: Compose isn't just about convenience — it's about reproducibility. The compose.yaml file serves as living documentation of your application's architecture, dependencies, and configuration. New team members can understand and run the entire stack in seconds.

Compose File Versions & History

Docker Compose has evolved significantly since its inception as "Fig" in 2014. Understanding this history helps you navigate legacy projects and modern best practices.

Era Version Key Features Status
2014v1 (Fig)Basic container linkingDeprecated
2016v2 / v2.xNetworks, volumes, build args, healthchecksLegacy
2017v3 / v3.xSwarm deploy, configs, secretsLegacy
2020+Compose SpecificationUnified spec, no version: needed, profiles, watchCurrent

The Compose Specification (Current Standard)

The modern Compose Specification (maintained at compose-spec.io) obsoletes all version numbers. You no longer need a version: key at the top of your file — if present, it's ignored by modern Docker Compose (v2+).

# Modern compose.yaml — NO version: key needed
# The Compose Specification is the current standard

services:
  webapp:
    image: nginx:alpine
    ports:
      - "80:80"

# Legacy format (still works but unnecessary):
# version: "3.8"
# services:
#   webapp:
#     image: nginx:alpine
File Naming: The default filename is compose.yaml (preferred) or docker-compose.yml (legacy). Compose searches for files in this order: compose.yaml, compose.yml, docker-compose.yaml, docker-compose.yml.

Core Concepts

A Compose file defines five top-level elements that together describe your application:

Docker Compose Architecture
flowchart TB
    subgraph ComposeFile["compose.yaml"]
        S["services:
Define containers"] N["networks:
Define connectivity"] V["volumes:
Define persistence"] C["configs:
Define configuration"] SE["secrets:
Define credentials"] end subgraph Runtime["Docker Engine Runtime"] S --> C1["Container 1
(webapp)"] S --> C2["Container 2
(database)"] S --> C3["Container 3
(cache)"] N --> NET1["frontend-net"] N --> NET2["backend-net"] V --> VOL1["db-data"] V --> VOL2["cache-data"] end C1 -.->|attached to| NET1 C1 -.->|attached to| NET2 C2 -.->|attached to| NET2 C3 -.->|attached to| NET2 C2 -.->|mounts| VOL1 C3 -.->|mounts| VOL2
Element Purpose Maps To
servicesDefine containers to run (image, build, config)docker run
networksDefine how services communicatedocker network create
volumesDefine persistent data storagedocker volume create
configsDefine non-sensitive configuration filesMounted as read-only files
secretsDefine sensitive data (passwords, keys)Mounted at /run/secrets/

Service Definition Deep Dive

Services are the heart of Compose. Each service defines a container with its image, configuration, resources, and relationships. Here's a comprehensive reference of service options:

# Complete service definition reference
services:
  webapp:
    # Image source (use image OR build, not both for clarity)
    image: myregistry.io/myapp:1.2.3
    
    # Container naming
    container_name: myapp-web    # Fixed name (avoid in scaled services)
    hostname: webapp             # Internal hostname
    
    # Port mapping: HOST:CONTAINER
    ports:
      - "8080:80"               # Map host 8080 to container 80
      - "443:443"               # HTTPS
      - "127.0.0.1:9090:9090"  # Bind to localhost only
    expose:
      - "3000"                  # Expose to other services only (no host mapping)
    
    # Environment configuration
    environment:
      NODE_ENV: production
      DATABASE_URL: postgres://db:5432/myapp
    env_file:
      - .env                    # Load from .env file
      - .env.local              # Override with local values
    
    # Volume mounts
    volumes:
      - app-data:/data          # Named volume
      - ./src:/app/src          # Bind mount (development)
      - ./config:/etc/app:ro    # Read-only bind mount
    
    # Network attachment
    networks:
      - frontend
      - backend
    
    # Restart policy
    restart: unless-stopped      # always | on-failure | no | unless-stopped
    
    # Resource limits (Compose Specification)
    deploy:
      resources:
        limits:
          cpus: "2.0"
          memory: 512M
        reservations:
          cpus: "0.5"
          memory: 128M
      replicas: 3               # Scale to 3 instances
    
    # Dependency ordering
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    
    # Health monitoring
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:80/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s
    
    # Security options
    user: "1000:1000"
    read_only: true
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE
    
    # Logging configuration
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"
    
    # Command override
    command: ["node", "server.js", "--port=80"]
    entrypoint: ["/docker-entrypoint.sh"]
    working_dir: /app

Build Configuration

Instead of using pre-built images, Compose can build images from Dockerfiles as part of the deployment process. This is essential for development workflows where you're iterating on the application code.

# Build configuration options
services:
  # Simple build (Dockerfile in current directory)
  api:
    build: .
    ports:
      - "3000:3000"

  # Full build configuration
  frontend:
    build:
      context: ./frontend          # Build context directory
      dockerfile: Dockerfile.prod  # Custom Dockerfile name
      args:                        # Build arguments
        NODE_VERSION: "20"
        BUILD_DATE: "2026-05-14"
      target: production           # Multi-stage build target
      cache_from:
        - myregistry.io/frontend:cache
      labels:
        - "com.example.version=1.0"
      platforms:
        - linux/amd64
        - linux/arm64
    image: myregistry.io/frontend:latest  # Tag the built image
    ports:
      - "80:80"

  # Multi-stage build targeting development stage
  api-dev:
    build:
      context: ./api
      dockerfile: Dockerfile
      target: development          # Stop at 'development' stage
    volumes:
      - ./api/src:/app/src         # Mount source for hot-reload
    command: ["npm", "run", "dev"]
# Build commands
docker compose build                  # Build all services with build: config
docker compose build --no-cache api   # Rebuild api without cache
docker compose build --parallel       # Build services in parallel
docker compose up --build             # Build and start (rebuild if changed)
docker compose push                   # Push built images to registry

Networking in Compose

Compose automatically creates a default network for your project. All services join this network and can reach each other by service name as the DNS hostname. Custom networks provide isolation and control.

Compose Network Topology
flowchart LR
    subgraph frontend-net["frontend network"]
        NGINX["nginx
(reverse proxy)"] APP["webapp
(application)"] end subgraph backend-net["backend network"] APP2["webapp
(application)"] DB["postgres
(database)"] REDIS["redis
(cache)"] end CLIENT["External
Client"] -->|port 80/443| NGINX NGINX -->|proxy_pass| APP APP2 -->|port 5432| DB APP2 -->|port 6379| REDIS APP -.->|same container
on both networks| APP2
# Multi-network isolation pattern
services:
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    networks:
      - frontend          # Only on frontend network
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro

  webapp:
    build: ./app
    networks:
      - frontend          # Reachable by nginx
      - backend           # Can reach database and cache
    environment:
      DATABASE_URL: postgres://postgres:secret@db:5432/myapp
      REDIS_URL: redis://redis:6379

  db:
    image: postgres:16
    networks:
      - backend           # Only on backend (nginx cannot reach it)
    volumes:
      - pgdata:/var/lib/postgresql/data
    environment:
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: myapp

  redis:
    image: redis:7-alpine
    networks:
      - backend           # Only on backend

# Custom network definitions
networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge
    internal: true        # No external internet access

volumes:
  pgdata:
# Network aliases and external networks
services:
  webapp:
    image: myapp:latest
    networks:
      backend:
        aliases:
          - api            # Reachable as 'api' AND 'webapp'
          - app-service
      shared:
        aliases:
          - myapp

networks:
  backend:
    driver: bridge
    ipam:
      config:
        - subnet: 172.28.0.0/16    # Custom subnet

  shared:
    external: true         # Use pre-existing network
    name: infrastructure_shared    # Actual network name
DNS Resolution: Within a Compose network, services resolve each other by service name. webapp can connect to the database at db:5432 — no IP addresses needed. Docker's embedded DNS server handles resolution automatically.

Volumes in Compose

Volumes provide persistent data storage that survives container restarts and removals. Compose supports named volumes (managed by Docker), bind mounts (host directories), and tmpfs mounts (in-memory).

# Volume strategies: development vs production
services:
  db:
    image: postgres:16
    volumes:
      # Named volume: persistent, Docker-managed, survives 'docker compose down'
      - pgdata:/var/lib/postgresql/data
      # Init scripts: bind mount for database initialization
      - ./init-scripts:/docker-entrypoint-initdb.d:ro

  webapp:
    build: ./app
    volumes:
      # Development: bind mount source code for hot-reload
      - ./src:/app/src
      # Development: bind mount for live config changes
      - ./config/dev.yaml:/app/config.yaml:ro
      # Named volume: persist node_modules (avoid host/container conflicts)
      - node_modules:/app/node_modules
      # tmpfs: in-memory for temp files (fast, ephemeral)
      - type: tmpfs
        target: /app/tmp
        tmpfs:
          size: 100000000    # 100MB limit

  redis:
    image: redis:7-alpine
    volumes:
      - redis-data:/data
    command: redis-server --appendonly yes    # Enable persistence

# Named volume definitions
volumes:
  pgdata:
    driver: local              # Default driver
  node_modules:                # No options needed
  redis-data:
    driver: local
    driver_opts:
      type: none
      o: bind
      device: /mnt/fast-ssd/redis-data    # Custom host path
# Volume management commands
docker compose down              # Stop containers (volumes PRESERVED)
docker compose down -v           # Stop containers AND delete named volumes
docker compose down --volumes    # Same as -v

# List volumes created by this project
docker volume ls --filter "label=com.docker.compose.project=myproject"

# Backup a named volume
docker run --rm -v myproject_pgdata:/data -v $(pwd):/backup \
  alpine tar czf /backup/pgdata-backup.tar.gz -C /data .

# Restore a volume from backup
docker run --rm -v myproject_pgdata:/data -v $(pwd):/backup \
  alpine tar xzf /backup/pgdata-backup.tar.gz -C /data

Environment Variables

Compose provides multiple mechanisms for injecting configuration into containers. Understanding the precedence order is critical for managing configurations across development, staging, and production environments.

# compose.yaml with environment variables
services:
  webapp:
    image: myapp:latest
    
    # Method 1: Inline key-value pairs
    environment:
      NODE_ENV: production
      PORT: "3000"
      # Variable substitution from host environment or .env file
      DATABASE_URL: postgres://${DB_USER}:${DB_PASS}@db:5432/${DB_NAME}
      # Default values with :- syntax
      LOG_LEVEL: ${LOG_LEVEL:-info}
      # Required variable (fails if unset)
      API_KEY: ${API_KEY:?API_KEY must be set}

    # Method 2: Load from file(s)
    env_file:
      - .env                # Base configuration
      - .env.${ENVIRONMENT:-development}  # Environment-specific overlay
# .env file (auto-loaded by Compose from project root)
# Used for variable substitution in compose.yaml AND passed to containers

# Database configuration
DB_USER=myapp
DB_PASS=development-password-only
DB_NAME=myapp_dev

# Application settings
LOG_LEVEL=debug
API_KEY=dev-key-not-for-production

# Compose project settings
COMPOSE_PROJECT_NAME=myapp
COMPOSE_PROFILES=debug

Variable Precedence (highest to lowest):

Priority Source Example
1 (highest)Shell environmentDB_PASS=prod docker compose up
2environment: in compose.yamlInline values override env_file
3env_file: contentsLoaded in order, later files override
4.env file (auto-loaded)Default values for substitution
5 (lowest)Dockerfile ENVBaked into image
# Interpolation syntax reference
${VARIABLE}              # Simple substitution
${VARIABLE:-default}     # Use 'default' if VARIABLE is unset or empty
${VARIABLE-default}      # Use 'default' only if VARIABLE is unset
${VARIABLE:+replacement} # Use 'replacement' if VARIABLE is set and non-empty
${VARIABLE:?error msg}   # Error with message if VARIABLE is unset or empty

# Validate your interpolated config
docker compose config    # Shows the fully resolved compose file

Healthchecks & Dependency Ordering

Container startup order is one of the most misunderstood aspects of Compose. depends_on without conditions only ensures containers start in order — it doesn't wait for services to be ready. Combining healthchecks with condition: service_healthy solves this.

# Proper dependency ordering with healthchecks
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: myapp
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d myapp"]
      interval: 5s
      timeout: 5s
      retries: 5
      start_period: 10s    # Grace period before first check

  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 3

  migrations:
    build: ./app
    command: ["python", "manage.py", "migrate"]
    depends_on:
      db:
        condition: service_healthy    # Wait for DB to accept connections
    restart: "no"                     # Run once and exit

  webapp:
    build: ./app
    ports:
      - "8000:8000"
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
      migrations:
        condition: service_completed_successfully  # Wait for migrations to finish
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 30s

volumes:
  pgdata:
Compose Startup Dependency Graph
flowchart LR
    DB["db
(postgres)"] -->|service_healthy| MIG["migrations"] DB -->|service_healthy| APP["webapp"] REDIS["redis"] -->|service_healthy| APP MIG -->|service_completed_successfully| APP APP -->|service_healthy| NGINX["nginx
(reverse proxy)"]
Common Mistake: Using depends_on: [db] without a condition only means "start db container first" — it does NOT wait for PostgreSQL to accept connections. Your app will crash with "connection refused" if it starts before the database is ready. Always use healthchecks with condition: service_healthy.

Dependency conditions available:

Condition Waits Until Use Case
service_startedContainer has started (default)Services that start fast
service_healthyHealthcheck passesDatabases, APIs
service_completed_successfullyContainer exits with code 0Migrations, setup scripts

Compose CLI Commands

The docker compose CLI (note: space, not hyphen) is the modern replacement for the legacy docker-compose binary. It's built into Docker Desktop and the Docker CLI plugin.

Command Purpose Common Flags
docker compose upCreate and start all services-d (detach), --build, --force-recreate
docker compose downStop and remove containers, networks-v (remove volumes), --rmi all
docker compose buildBuild or rebuild services--no-cache, --parallel
docker compose logsView output from services-f (follow), --tail=100
docker compose execExecute command in running container-it (interactive), -u root
docker compose runRun one-off command in new container--rm, --no-deps
docker compose psList containers and their status-a (all), --format json
docker compose restartRestart services-t (timeout)
docker compose configValidate and show resolved config--services, --volumes
docker compose pullPull service images--quiet, --ignore-buildable
docker compose stopStop services (don't remove)-t (timeout)
docker compose watchWatch for changes and sync/rebuild(Compose 2.22+)
# Essential workflow commands
# Start everything in background
docker compose up -d

# View logs from specific services
docker compose logs -f webapp db

# Execute a shell in a running service
docker compose exec webapp sh

# Run a one-off command (e.g., database migration)
docker compose run --rm webapp python manage.py migrate

# Scale a service (if no fixed container_name)
docker compose up -d --scale webapp=3

# View resource usage
docker compose top

# Validate compose file without running
docker compose config --quiet && echo "Valid!" || echo "Invalid!"

# Remove everything including volumes and images
docker compose down -v --rmi all

Real-World Project: Flask + Redis + PostgreSQL

Let's build a complete, production-ready multi-service application: a Flask API with Redis caching and PostgreSQL persistence. This demonstrates services, networking, volumes, healthchecks, and proper dependency ordering.

# compose.yaml — Flask + Redis + PostgreSQL
# Production-ready with healthchecks, resource limits, and security

services:
  # === PostgreSQL Database ===
  db:
    image: postgres:16-alpine
    container_name: myapp-db
    environment:
      POSTGRES_USER: ${DB_USER:-myapp}
      POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD required}
      POSTGRES_DB: ${DB_NAME:-myapp}
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./init-db:/docker-entrypoint-initdb.d:ro
    networks:
      - backend
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-myapp} -d ${DB_NAME:-myapp}"]
      interval: 5s
      timeout: 5s
      retries: 5
      start_period: 10s
    deploy:
      resources:
        limits:
          memory: 512M
          cpus: "1.0"
    restart: unless-stopped

  # === Redis Cache ===
  redis:
    image: redis:7-alpine
    container_name: myapp-redis
    command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru
    volumes:
      - redis-data:/data
    networks:
      - backend
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 3
    deploy:
      resources:
        limits:
          memory: 256M
          cpus: "0.5"
    restart: unless-stopped

  # === Flask Application ===
  webapp:
    build:
      context: ./app
      dockerfile: Dockerfile
      target: production
    container_name: myapp-web
    ports:
      - "${APP_PORT:-8000}:8000"
    environment:
      FLASK_ENV: production
      DATABASE_URL: postgresql://${DB_USER:-myapp}:${DB_PASSWORD}@db:5432/${DB_NAME:-myapp}
      REDIS_URL: redis://redis:6379/0
      SECRET_KEY: ${SECRET_KEY:?SECRET_KEY required}
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      - frontend
      - backend
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 20s
    deploy:
      resources:
        limits:
          memory: 256M
          cpus: "1.0"
      replicas: 2
    restart: unless-stopped
    read_only: true
    security_opt:
      - no-new-privileges:true

  # === Celery Worker (Background Tasks) ===
  worker:
    build:
      context: ./app
      target: production
    command: ["celery", "-A", "app.celery", "worker", "--loglevel=info"]
    environment:
      DATABASE_URL: postgresql://${DB_USER:-myapp}:${DB_PASSWORD}@db:5432/${DB_NAME:-myapp}
      REDIS_URL: redis://redis:6379/0
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      - backend
    deploy:
      resources:
        limits:
          memory: 512M
    restart: unless-stopped

networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge
    internal: true    # No internet access for backend services

volumes:
  pgdata:
  redis-data:
# .env file for the Flask project
DB_USER=myapp
DB_PASSWORD=change-me-in-production
DB_NAME=myapp
SECRET_KEY=dev-secret-key-change-in-production
APP_PORT=8000
Architecture Pattern
Network Isolation in This Stack

The backend network has internal: true, meaning containers on it cannot reach the internet. The database and Redis are completely isolated — only the webapp and worker (which are on both networks) can reach them. External traffic enters only through the webapp's published port on the frontend network.

network-isolation defense-in-depth least-privilege

Real-World Project: Nginx Reverse Proxy + App + DB

A production-like deployment with Nginx as a reverse proxy handling TLS termination, rate limiting, and static file serving — fronting your application server.

# compose.yaml — Production Nginx + Node.js + PostgreSQL
services:
  # === Nginx Reverse Proxy ===
  nginx:
    image: nginx:1.25-alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./certbot/www:/var/www/certbot:ro
      - ./certbot/conf:/etc/letsencrypt:ro
      - static-files:/var/www/static:ro
    depends_on:
      app:
        condition: service_healthy
    networks:
      - frontend
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost/nginx-health"]
      interval: 10s
      timeout: 5s
      retries: 3
    restart: unless-stopped

  # === Node.js Application ===
  app:
    build:
      context: ./app
      target: production
    expose:
      - "3000"                # Internal only — Nginx proxies to it
    environment:
      NODE_ENV: production
      DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/myapp
      REDIS_URL: redis://redis:6379
      TRUST_PROXY: "true"    # Trust Nginx X-Forwarded-* headers
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      - frontend
      - backend
    healthcheck:
      test: ["CMD", "node", "-e", "fetch('http://localhost:3000/health').then(r=>{if(!r.ok)throw Error();process.exit(0)}).catch(()=>process.exit(1))"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 15s
    deploy:
      replicas: 2
      resources:
        limits:
          memory: 256M
          cpus: "1.0"
    restart: unless-stopped

  # === PostgreSQL ===
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
      POSTGRES_DB: myapp
    volumes:
      - pgdata:/var/lib/postgresql/data
    networks:
      - backend
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
      timeout: 3s
      retries: 5
      start_period: 10s
    deploy:
      resources:
        limits:
          memory: 512M
    restart: unless-stopped

  # === Redis ===
  redis:
    image: redis:7-alpine
    command: redis-server --save 60 1000 --loglevel warning
    volumes:
      - redis-data:/data
    networks:
      - backend
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 3
    restart: unless-stopped

  # === Certbot (Let's Encrypt) ===
  certbot:
    image: certbot/certbot
    volumes:
      - ./certbot/www:/var/www/certbot
      - ./certbot/conf:/etc/letsencrypt
    entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done'"
    depends_on:
      - nginx
    profiles:
      - production           # Only runs in production profile

networks:
  frontend:
  backend:
    internal: true

volumes:
  pgdata:
  redis-data:
  static-files:
# nginx/conf.d/default.conf
upstream app_servers {
    # Docker's DNS resolves 'app' to all replicas
    server app:3000;
}

server {
    listen 80;
    server_name example.com;
    
    # Health check endpoint for Compose healthcheck
    location /nginx-health {
        access_log off;
        return 200 "healthy\n";
    }
    
    # Certbot challenge
    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }
    
    # Redirect to HTTPS
    location / {
        return 301 https://$host$request_uri;
    }
}

server {
    listen 443 ssl;
    server_name example.com;
    
    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    
    # Static files served directly by Nginx
    location /static/ {
        alias /var/www/static/;
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
    
    # Proxy to Node.js application
    location / {
        proxy_pass http://app_servers;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Profiles & Multiple Environments

Compose profiles let you define optional services that only start when explicitly activated. Combined with override files, this provides powerful environment management without maintaining separate compose files.

# compose.yaml with profiles
services:
  webapp:
    build: ./app
    ports:
      - "8000:8000"
    # No profiles = always starts

  db:
    image: postgres:16
    # No profiles = always starts

  # Debug tools — only with 'debug' profile
  pgadmin:
    image: dpage/pgadmin4
    ports:
      - "5050:80"
    environment:
      PGADMIN_DEFAULT_EMAIL: admin@example.com
      PGADMIN_DEFAULT_PASSWORD: admin
    profiles:
      - debug

  # Monitoring — only with 'monitoring' profile
  prometheus:
    image: prom/prometheus
    ports:
      - "9090:9090"
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
    profiles:
      - monitoring

  grafana:
    image: grafana/grafana
    ports:
      - "3000:3000"
    profiles:
      - monitoring

  # Testing — only with 'test' profile
  test-runner:
    build:
      context: ./app
      target: test
    command: ["pytest", "--cov=app", "-v"]
    depends_on:
      db:
        condition: service_healthy
    profiles:
      - test
# Profile activation
docker compose up -d                          # Only webapp + db (no profiles)
docker compose --profile debug up -d          # webapp + db + pgadmin
docker compose --profile monitoring up -d     # webapp + db + prometheus + grafana
docker compose --profile debug --profile monitoring up -d  # All optional services

# Multiple profiles via environment variable
export COMPOSE_PROFILES=debug,monitoring
docker compose up -d

# Run tests
docker compose run --rm --profile test test-runner

Override files provide environment-specific configuration without modifying the base file:

# compose.override.yaml — automatically loaded for development
# Overrides/extends the base compose.yaml

services:
  webapp:
    build:
      target: development         # Use dev stage instead of production
    volumes:
      - ./src:/app/src            # Hot-reload source code
    environment:
      DEBUG: "true"
      LOG_LEVEL: debug
    command: ["npm", "run", "dev"]

  db:
    ports:
      - "5432:5432"               # Expose DB port for local tools
# File loading behavior
docker compose up                 # Loads: compose.yaml + compose.override.yaml (auto)
docker compose -f compose.yaml up # Loads: ONLY compose.yaml (skips override)

# Production: explicitly specify files
docker compose -f compose.yaml -f compose.prod.yaml up -d

# Or use COMPOSE_FILE environment variable
export COMPOSE_FILE=compose.yaml:compose.prod.yaml
docker compose up -d

Compose Watch (Development Hot-Reload)

Compose Watch (introduced in Docker Compose 2.22) provides automatic file synchronization and rebuild triggers for development workflows. It replaces manual bind mounts with intelligent sync that handles file events properly.

# compose.yaml with watch configuration
services:
  webapp:
    build: ./app
    ports:
      - "3000:3000"
    develop:
      watch:
        # Sync source files without rebuild (hot-reload)
        - action: sync
          path: ./app/src
          target: /app/src
          ignore:
            - node_modules/
            - "**/*.test.js"

        # Sync and restart the container (config changes)
        - action: sync+restart
          path: ./app/config
          target: /app/config

        # Full rebuild when dependencies change
        - action: rebuild
          path: ./app/package.json

        # Rebuild on Dockerfile changes
        - action: rebuild
          path: ./app/Dockerfile

  frontend:
    build: ./frontend
    ports:
      - "5173:5173"
    develop:
      watch:
        - action: sync
          path: ./frontend/src
          target: /app/src
        - action: rebuild
          path: ./frontend/package.json
# Start with watch mode
docker compose watch

# Or start detached services and watch
docker compose up -d
docker compose watch    # Watches in foreground, syncs changes

# Watch actions explained:
# sync        → Copy changed files into container (no restart)
# sync+restart → Copy files and restart the container process
# rebuild     → Trigger full docker compose build + recreate
Watch vs Bind Mounts: Compose Watch provides better cross-platform performance than bind mounts (especially on macOS/Windows where filesystem event propagation to Linux VMs is slow). It also handles file permissions correctly and supports ignore patterns, making it the recommended approach for development workflows on Docker Desktop.

Exercises

Exercise 1: Create a compose.yaml for a WordPress site with MySQL. Include healthchecks for both services, a named volume for database persistence, and proper dependency ordering. Verify the site starts with docker compose up -d.
Exercise 2: Build a multi-network Compose file with three services: a frontend (accessible from host), a backend API, and a database. Use network isolation so the frontend cannot directly connect to the database. Verify isolation with docker compose exec frontend ping db (should fail).
Exercise 3: Create a Compose file that uses profiles: base services always run, a debug profile adds pgAdmin and Redis Commander, and a test profile runs your test suite. Demonstrate activating different profiles.
Exercise 4: Implement Compose Watch for a Node.js application. Configure sync for source files, sync+restart for config changes, and rebuild for package.json changes. Verify that editing a source file immediately reflects in the running container.
Exercise 5: Create a compose.yaml and compose.override.yaml pair. The base file defines production settings; the override adds development features (exposed ports, bind mounts, debug mode). Show the resolved configuration with docker compose config.

Conclusion & Next Steps

Docker Compose transforms multi-container chaos into declarative, reproducible application stacks. The key concepts we've mastered:

  • Services define what containers to run, how to build them, and what resources they need
  • Networks provide service-to-service communication with DNS resolution and isolation
  • Volumes persist data beyond container lifecycle with different strategies for development and production
  • Healthchecks with depends_on conditions ensure proper startup ordering and service readiness
  • Profiles and override files manage environment-specific configuration without file duplication
  • Compose Watch provides modern hot-reload for development workflows

However, Compose operates on a single Docker host. It cannot schedule containers across multiple machines, automatically scale based on load, or self-heal when nodes fail. These capabilities require a container orchestrator — and preparing your containers for orchestration is the focus of our next article.

Next in the Series

In Part 19: Container Orchestration Readiness, we'll bridge from Docker to Kubernetes — designing stateless containers, implementing health probes, handling graceful shutdown, and adopting the twelve-factor principles that make containers orchestration-ready.