← Course Index

Docker Compose — Multi-Container Apps

~30 min · Docker · Docker Cheat Sheet →

Ref
Primary Source
Docker Docs — Docker Compose Getting Started

Hands-on tutorial for running multi-container applications. Follow it alongside this lesson. docs.docker.com →

Why Compose?

Real apps don't run in isolation. Your Node.js API needs a database, Redis for caching, maybe RabbitMQ for queues. Running each container with docker run manually — with all the right flags, networks, and volumes — is a nightmare. Compose defines your entire multi-container stack in one YAML file.

💡 JS translation

docker-compose.yml is like a package.json for your infrastructure. Instead of npm install, you run docker compose up to spin up the entire stack.

A Production-Grade docker-compose.yml

This runs a Node.js API with PostgreSQL, Redis, and RabbitMQ:

version: "3.9"

services:

  # ── Your Application ──────────────────────────────────────────
  api:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "3000:3000"
    environment:
      NODE_ENV: production
      DATABASE_URL: postgres://user:password@db:5432/myapp
      REDIS_URL: redis://redis:6379
      RABBITMQ_URL: amqp://user:password@rabbitmq:5672
    env_file:
      - .env                # Load additional secrets from .env
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
      rabbitmq:
        condition: service_healthy
    restart: unless-stopped
    networks:
      - app-network

  # ── Background Worker ─────────────────────────────────────────
  worker:
    build: .
    command: node workers/email-worker.js
    environment:
      NODE_ENV: production
      RABBITMQ_URL: amqp://user:password@rabbitmq:5672
    depends_on:
      rabbitmq:
        condition: service_healthy
    restart: unless-stopped
    networks:
      - app-network

  # ── PostgreSQL ────────────────────────────────────────────────
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: user
      POSTGRES_PASSWORD: password
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user -d myapp"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - app-network

  # ── Redis ─────────────────────────────────────────────────────
  redis:
    image: redis:7-alpine
    command: redis-server --appendonly yes  # Enable AOF persistence
    volumes:
      - redis-data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 3
    networks:
      - app-network

  # ── RabbitMQ ──────────────────────────────────────────────────
  rabbitmq:
    image: rabbitmq:3-management-alpine
    environment:
      RABBITMQ_DEFAULT_USER: user
      RABBITMQ_DEFAULT_PASS: password
    ports:
      - "15672:15672"    # Management UI: localhost:15672
    volumes:
      - rabbitmq-data:/var/lib/rabbitmq
    healthcheck:
      test: ["CMD", "rabbitmq-diagnostics", "ping"]
      interval: 15s
      timeout: 10s
      retries: 5
    networks:
      - app-network

# ── Named volumes (persist data across restarts) ───────────────
volumes:
  postgres-data:
  redis-data:
  rabbitmq-data:

# ── Internal network (containers talk to each other by name) ──
networks:
  app-network:
    driver: bridge

Key Concepts

Service Discovery by Name

Inside the Docker network, containers find each other by service name. Your API connects to redis://redis:6379 — not localhost. The service name is the hostname.

Volumes — Persistent Data

Containers are ephemeral. Stop them, their filesystem is gone. Named volumes persist data independently of the container lifecycle. Always use volumes for databases.

Health Checks + depends_on

depends_on with condition: service_healthy means "don't start my API until the database is actually ready to accept connections" — not just "until the container is started." This prevents race conditions on startup.

restart: unless-stopped

Automatically restarts the container if it crashes. Stops only if you explicitly docker compose down it.

Compose Commands

# Start all services (detached)
docker compose up -d

# Start with rebuild (use after code changes)
docker compose up -d --build

# View logs (all services)
docker compose logs -f

# View logs for one service
docker compose logs -f api

# Check status
docker compose ps

# Run a one-off command
docker compose exec api sh
docker compose exec db psql -U user myapp

# Stop everything (keep volumes)
docker compose down

# Stop and delete all volumes (DESTROYS DATA)
docker compose down -v

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

Dev vs Production Compose

Use separate Compose files for development and production. In dev, you want hot-reload and no build step:

# docker-compose.dev.yml — overrides for local dev
version: "3.9"
services:
  api:
    build:
      target: dev        # Use a dev stage in your Dockerfile
    volumes:
      - .:/app           # Mount source code for hot-reload
      - /app/node_modules
    command: npx nodemon server.js

# Run dev stack
docker compose -f docker-compose.yml -f docker-compose.dev.yml up

Check Your Understanding

1. Your API service crashes because the database wasn't ready yet. What Compose feature prevents this?
2. Inside a Docker network, how does your API container connect to Redis?
3. You run `docker compose down`. What happens to your PostgreSQL data?
4. You want to run 3 instances of your worker service for parallel job processing. Which command?