Skip to main content
Docker Fundamentals·Lesson 5 of 5

Docker Compose

Docker Compose lets you define an entire multi-container application in a single YAML file. Instead of running multiple docker run commands with long flags, you describe your stack declaratively and bring it all up with one command.

Why Docker Compose?

In the previous lesson, you ran a web app, database, and cache with three separate docker run commands. That approach has problems:

  • Hard to remember all the flags
  • Easy to make mistakes
  • Difficult to share with teammates
  • No easy way to restart the entire stack

Docker Compose solves all of this.

Your First Compose File

Create a file called docker-compose.yml:

services:
  web:
    image: nginx
    ports:
      - "8080:80"
    volumes:
      - ./html:/usr/share/nginx/html:ro

Start it:

docker compose up -d

Stop it:

docker compose down

Compose File Structure

A complete docker-compose.yml for a full-stack app:

services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgres://app:secret@db:5432/myapp
      - REDIS_URL=redis://cache:6379
      - NODE_ENV=production
    depends_on:
      - db
      - cache
    restart: unless-stopped

  db:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: myapp
    volumes:
      - pgdata:/var/lib/postgresql/data
    ports:
      - "5432:5432"
    restart: unless-stopped

  cache:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    restart: unless-stopped

volumes:
  pgdata:

Service Configuration Options

Here are the most commonly used options:

services:
  my-service:
    # Build from a Dockerfile
    build:
      context: .
      dockerfile: Dockerfile.prod
      args:
        NODE_ENV: production

    # Or use a pre-built image
    image: node:20-alpine

    # Container name (optional)
    container_name: my-app

    # Port mapping
    ports:
      - "3000:3000"
      - "9229:9229"

    # Environment variables
    environment:
      NODE_ENV: production
      API_KEY: ${API_KEY}  # From host environment or .env file

    # Load env from a file
    env_file:
      - .env

    # Mount volumes
    volumes:
      - ./src:/app/src        # Bind mount
      - node_modules:/app/node_modules  # Named volume

    # Start after these services
    depends_on:
      - db
      - cache

    # Restart policy
    restart: unless-stopped

    # Override the default command
    command: ["node", "server.js"]

    # Connect to specific networks
    networks:
      - frontend
      - backend

    # Resource limits
    deploy:
      resources:
        limits:
          memory: 512M
          cpus: "1.0"

Essential Compose Commands

# Start all services in the background
docker compose up -d

# Start and rebuild images
docker compose up -d --build

# Stop all services
docker compose down

# Stop and remove volumes (deletes persistent data)
docker compose down -v

# View running services
docker compose ps

# View logs
docker compose logs

# Follow logs for a specific service
docker compose logs -f app

# Restart a specific service
docker compose restart app

# Scale a service
docker compose up -d --scale app=3

# Execute a command in a running service
docker compose exec app bash

# Run a one-off command
docker compose run --rm app npm test

Environment Variables

Compose supports multiple ways to pass environment variables:

Inline in the compose file:

services:
  app:
    environment:
      - NODE_ENV=production
      - PORT=3000

From a .env file (loaded automatically if present):

# .env
POSTGRES_PASSWORD=secret
API_KEY=abc123
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
  app:
    environment:
      API_KEY: ${API_KEY}

From a separate env file:

services:
  app:
    env_file:
      - .env.production

depends_on and Health Checks

depends_on controls startup order, but by default it only waits for the container to start, not for the service inside to be ready. Use health checks for true readiness:

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: secret
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5

  app:
    build: .
    depends_on:
      db:
        condition: service_healthy

Networks in Compose

Compose creates a default network for all services. You can define custom networks:

services:
  nginx:
    image: nginx
    networks:
      - frontend

  api:
    build: ./api
    networks:
      - frontend
      - backend

  db:
    image: postgres:16
    networks:
      - backend

networks:
  frontend:
  backend:

The database is only reachable from the API, not from Nginx.

Development vs Production Compose

Use multiple Compose files or profiles for different environments:

docker-compose.yml (base):

services:
  app:
    build: .
    environment:
      - NODE_ENV=production
    ports:
      - "3000:3000"

docker-compose.override.yml (development overrides, loaded automatically):

services:
  app:
    build:
      target: development
    environment:
      - NODE_ENV=development
    volumes:
      - ./src:/app/src
    command: ["npm", "run", "dev"]
# Development (uses both files automatically)
docker compose up

# Production (only base file)
docker compose -f docker-compose.yml up -d

Profiles

Group services that should only run in certain contexts:

services:
  app:
    build: .
    ports:
      - "3000:3000"

  db:
    image: postgres:16
    volumes:
      - pgdata:/var/lib/postgresql/data

  adminer:
    image: adminer
    ports:
      - "8080:8080"
    profiles:
      - debug

  mailhog:
    image: mailhog/mailhog
    ports:
      - "1025:1025"
      - "8025:8025"
    profiles:
      - debug

volumes:
  pgdata:
# Start without debug tools
docker compose up -d

# Start with debug tools
docker compose --profile debug up -d

Full-Stack Example: Next.js + PostgreSQL + Redis

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgres://app:secret@db:5432/myapp
      REDIS_URL: redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    restart: unless-stopped

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: myapp
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d myapp"]
      interval: 5s
      timeout: 5s
      retries: 5
    restart: unless-stopped

  cache:
    image: redis:7-alpine
    command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru
    restart: unless-stopped

volumes:
  pgdata:
# Start the entire stack
docker compose up -d

# Check all services are running
docker compose ps

# View combined logs
docker compose logs -f

# Tear everything down
docker compose down

Summary

Docker Compose turns multi-container applications into simple, declarative YAML files. You learned how to define services, configure networks and volumes, manage environments, and use health checks for proper startup ordering. With Compose, your entire development stack is version-controlled and reproducible.