Docker Compose Explained: A Practical Guide for Developers

12 min readDevOps & Containers

Introduction

Docker Compose is the standard tool for defining and running multi-container Docker applications. With a single YAML file, you can configure all your services, networks, and volumes, then spin up the entire stack with one command. This guide covers everything from basic syntax to production best practices, with real examples you can use immediately.

What is Docker Compose?

Docker Compose lets you define a multi-container application in a compose.yaml file. Instead of running multiple docker run commands with long flag lists, you describe your entire stack declaratively:

# compose.yaml
services:
  web:
    image: nginx:alpine
    ports:
      - "8080:80"
    volumes:
      - ./html:/usr/share/nginx/html
    depends_on:
      - api

  api:
    build: ./api
    environment:
      - DATABASE_URL=postgres://db:5432/myapp
    depends_on:
      - db

  db:
    image: postgres:16
    environment:
      - POSTGRES_DB=myapp
      - POSTGRES_PASSWORD=secret
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

Run docker compose up and all three services start together, connected on a shared network, with proper dependency ordering.

Core Concepts

Services

A service is a container definition. Each service runs from an image or a build context. Services on the same Compose file share a default network and can reference each other by name:

services:
  api:
    image: node:20-alpine
    # api can reach db at hostname "db"
    environment:
      - DB_HOST=db

  db:
    image: postgres:16

Networks

By default, Compose creates a bridge network for your app. You can define custom networks to isolate services:

services:
  web:
    networks:
      - frontend
  api:
    networks:
      - frontend
      - backend
  db:
    networks:
      - backend

networks:
  frontend:
  backend:

Volumes

Volumes persist data across container restarts. Use named volumes for databases and bind mounts for local development:

services:
  api:
    volumes:
      - ./src:/app/src          # bind mount (dev hot-reload)
      - node_modules:/app/node_modules  # named volume

  db:
    volumes:
      - pgdata:/var/lib/postgresql/data  # named volume (persist DB)

volumes:
  pgdata:
  node_modules:

Common Commands

CommandWhat It Does
docker compose upStart all services (foreground)
docker compose up -dStart in background (detached)
docker compose downStop and remove containers, networks
docker compose down -vAlso remove named volumes
docker compose logs -fFollow logs from all services
docker compose psList running services
docker compose buildRebuild images
docker compose exec api shOpen shell in running container

Real-World Example: Full-Stack App

# compose.yaml - Full-stack application
services:
  # React frontend (dev server)
  web:
    build:
      context: ./frontend
      target: dev
    ports:
      - "3000:3000"
    volumes:
      - ./frontend/src:/app/src
    environment:
      - VITE_API_URL=http://localhost:8080
    depends_on:
      - api

  # Node.js API server
  api:
    build: ./api
    ports:
      - "8080:8080"
    environment:
      - DATABASE_URL=postgres://postgres:secret@db:5432/myapp
      - REDIS_URL=redis://cache:6379
      - JWT_SECRET=${JWT_SECRET}
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started

  # PostgreSQL database
  db:
    image: postgres:16-alpine
    environment:
      - POSTGRES_DB=myapp
      - POSTGRES_PASSWORD=secret
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5

  # Redis cache
  cache:
    image: redis:7-alpine
    ports:
      - "6379:6379"

volumes:
  pgdata:

Environment Variables

Never hardcode secrets in your Compose file. Use environment variables:

# compose.yaml
services:
  api:
    environment:
      - DATABASE_URL=${DATABASE_URL}
      - JWT_SECRET=${JWT_SECRET}
    env_file:
      - .env.local
# .env.local (add to .gitignore!)
DATABASE_URL=postgres://postgres:secret@db:5432/myapp
JWT_SECRET=your-super-secret-key-here

Best Practices

  • Use compose.yaml, not docker-compose.yml - The new standard filename (Compose V2).
  • Pin image versions - Use postgres:16-alpine, not postgres:latest.
  • Use healthchecks - Let Compose know when a service is truly ready, not just started.
  • Use .env files for secrets - Never commit passwords to version control.
  • Separate dev and prod - Use compose.override.yaml for dev-specific settings.
  • Validate before running - Use our Docker Compose Validator to catch errors.

Related Resources