Docker Compose

intermediate docker docker-compose yaml multi-container

Most real apps don’t run alone. We have our app, a database, maybe a cache, a reverse proxy. Running each container manually with long docker run commands gets painful fast. Docker Compose lets us define all our services in a single YAML file and bring everything up with one command.

Instead of running five docker run commands with flags we’ll definitely mistype, we write a docker-compose.yml once and run docker compose up. Done.

The docker-compose.yml Structure

A compose file defines services (containers), networks (how they talk to each other), and volumes (persistent data). Here’s the anatomy:

# docker-compose.yml
services:
  # Each service becomes a container
  app:
    build: .                      # build from Dockerfile in current dir
    ports:
      - "3000:3000"               # host:container port mapping
    environment:
      - NODE_ENV=production       # env vars
      - DATABASE_URL=postgres://user:pass@db:5432/mydb
    volumes:
      - ./src:/app/src            # bind mount for hot reload
    depends_on:
      - db                        # start db before app
      - redis

  db:
    image: postgres:15            # pull from Docker Hub
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
      POSTGRES_DB: mydb
    volumes:
      - pgdata:/var/lib/postgresql/data   # named volume
    ports:
      - "5432:5432"

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

# Named volumes (Docker manages the storage location)
volumes:
  pgdata:

That’s it. Three services, networking, persistence — all in one file.

A Complete Practical Example

Let’s say we’re building a Node.js API with PostgreSQL and Redis. Here’s a real-world compose file:

services:
  api:
    build:
      context: .                  # Dockerfile location
      dockerfile: Dockerfile      # explicit Dockerfile name
    ports:
      - "3000:3000"
    env_file:
      - .env                      # load vars from .env file
    volumes:
      - ./src:/app/src            # hot reload during development
    depends_on:
      - postgres
      - redis
    restart: unless-stopped        # auto-restart on crash

  postgres:
    image: postgres:15-alpine
    environment:
      POSTGRES_USER: ${DB_USER}    # reads from .env or shell env
      POSTGRES_PASSWORD: ${DB_PASS}
      POSTGRES_DB: ${DB_NAME}
    volumes:
      - pgdata:/var/lib/postgresql/data
    ports:
      - "5432:5432"

  redis:
    image: redis:7-alpine
    command: redis-server --maxmemory 256mb  # override default command
    ports:
      - "6379:6379"

volumes:
  pgdata:

Essential Commands

These are the compose commands we use daily.

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

# Stop and remove all containers, networks
docker compose down

# Stop and remove everything INCLUDING volumes (careful — deletes data)
docker compose down -v

# Rebuild images before starting (after Dockerfile changes)
docker compose build
docker compose up -d --build      # shorthand: build + start

# View logs from all services
docker compose logs -f            # follow all logs
docker compose logs -f api        # follow only the api service

# Run a command inside a running service
docker compose exec postgres psql -U user -d mydb

# List running services
docker compose ps

Networking — Services Talk by Name

This is one of the best parts of Compose. All services defined in the same compose file are automatically on the same network. They can reach each other using the service name as the hostname.

Compose Network (auto-created)
api
:3000
postgres
:5432
redis
:6379
api connects to postgres via postgres://user:pass@postgres:5432/mydb
api connects to redis via redis://redis:6379

Notice we use postgres and redis as hostnames — not localhost. Inside the Compose network, each service is reachable by its name. This is why the database URL uses @db:5432 instead of @localhost:5432.

Environment Variables in Compose

There are three ways to pass env vars to services:

services:
  app:
    # 1. Inline — directly in the compose file
    environment:
      - NODE_ENV=production
      - API_KEY=abc123

    # 2. From a file — load all vars from a .env file
    env_file:
      - .env
      - .env.local               # can load multiple files

    # 3. Variable substitution — reference host env or .env file
    environment:
      - DB_HOST=${DB_HOST:-localhost}   # default value if not set

Compose automatically reads a .env file in the same directory as the compose file. So ${DB_USER} in the YAML will be replaced with whatever DB_USER is set to in .env.

The depends_on Gotcha

depends_on controls startup order — it makes sure db starts before app. But here’s the catch: it only waits for the container to start, not for the service inside it to be ready.

PostgreSQL takes a few seconds to initialize. If our app tries to connect immediately, it might fail because Postgres isn’t accepting connections yet.

services:
  app:
    depends_on:
      db:
        condition: service_healthy   # wait for healthcheck to pass
  db:
    image: postgres:15
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user"]
      interval: 5s
      timeout: 5s
      retries: 5

With condition: service_healthy, Compose waits until the database healthcheck passes before starting our app. This is the proper way to handle it.

Dev vs Production Compose Files

For development we want hot reload, exposed ports for debugging, and verbose logging. For production, none of that. We can use multiple compose files:

# Development — uses both files, dev overrides base
docker compose -f docker-compose.yml -f docker-compose.dev.yml up

# Production — base file is usually enough
docker compose up -d

In simple language, Docker Compose lets us define our entire multi-container stack in one YAML file — services talk to each other by name, volumes keep data alive, and one command brings everything up or tears it down.