In the previous tutorial, we learned how to build a Docker image for a single application. But most real applications need more than one service. A web app might need a database. An API might need a cache. A backend might need a message queue.

Running each service with docker run and connecting them manually is tedious. Docker Compose solves this. It lets you define all your services in one file and start everything with a single command.

What Is Docker Compose?

Docker Compose is a tool that comes with Docker Desktop. It reads a YAML file called docker-compose.yml (or compose.yml) and creates all the containers, networks, and volumes defined in it.

Instead of running multiple docker run commands with long flags, you write a configuration file once and use docker compose up.

The docker-compose.yml File

Create a new folder called my-project:

mkdir my-project
cd my-project

Create a file called docker-compose.yml:

services:
  app:
    image: nginx:alpine
    ports:
      - "8080:80"

This is the simplest Compose file. It defines one service called app that uses the nginx:alpine image and maps port 8080 to port 80.

Start it:

docker compose up

Open http://localhost:8080 in your browser. You should see the Nginx welcome page.

Press Ctrl+C to stop.

Understanding the Structure

A docker-compose.yml file has a clear structure. Here is a more complete example:

services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgres://alex:secret@db:5432/mydb
    depends_on:
      - db

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: alex
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: mydb
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

Let’s break this down.

Services

Each service is a container. In this example, we have two services:

  • app — our application
  • db — a PostgreSQL database

build vs image

# Build from a Dockerfile in the current directory
app:
  build: .

# Use a pre-built image from Docker Hub
db:
  image: postgres:16-alpine

Use build when you have your own application with a Dockerfile. Use image when you want a pre-built image.

ports

ports:
  - "3000:3000"

Maps port 3000 on your computer to port 3000 in the container. The format is host:container.

environment

You can set environment variables in two ways:

# List format
environment:
  - DATABASE_URL=postgres://alex:secret@db:5432/mydb
  - NODE_ENV=production

# Map format
environment:
  POSTGRES_USER: alex
  POSTGRES_PASSWORD: secret

Both formats work. Pick one and be consistent.

depends_on

app:
  depends_on:
    - db

This tells Docker Compose to start db before app. It controls startup order. However, it does not wait for the database to be ready — it only waits for the container to start. We will discuss this later.

volumes

db:
  volumes:
    - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

This creates a named volume called pgdata. The database stores its data in /var/lib/postgresql/data. Even if the container is removed, the data stays in the volume. We cover volumes in detail in Docker Tutorial #4.

Practical Example: App + PostgreSQL

Let’s build a real example. We will create a Python app that connects to a PostgreSQL database.

Create this folder structure:

my-project/
  app.py
  requirements.txt
  Dockerfile
  docker-compose.yml

app.py:

import os
import time
import psycopg2
from flask import Flask, jsonify

app = Flask(__name__)

def get_db_connection():
    return psycopg2.connect(os.environ["DATABASE_URL"])

def init_db():
    conn = get_db_connection()
    cur = conn.cursor()
    cur.execute("""
        CREATE TABLE IF NOT EXISTS visits (
            id SERIAL PRIMARY KEY,
            timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        )
    """)
    conn.commit()
    cur.close()
    conn.close()

@app.route("/")
def index():
    conn = get_db_connection()
    cur = conn.cursor()
    cur.execute("INSERT INTO visits DEFAULT VALUES")
    cur.execute("SELECT COUNT(*) FROM visits")
    count = cur.fetchone()[0]
    conn.commit()
    cur.close()
    conn.close()
    return jsonify({"message": "Hello from Docker!", "visits": count})

if __name__ == "__main__":
    time.sleep(2)  # Wait for database to be ready
    init_db()
    app.run(host="0.0.0.0", port=5000)

requirements.txt:

flask==3.1.0
psycopg2-binary==2.9.10

Dockerfile:

FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 5000

CMD ["python", "app.py"]

docker-compose.yml:

services:
  app:
    build: .
    ports:
      - "5000:5000"
    environment:
      - DATABASE_URL=postgres://alex:secret@db:5432/mydb
    depends_on:
      - db

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: alex
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: mydb
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

Start everything:

docker compose up --build

The --build flag tells Compose to build the app image before starting. Open http://localhost:5000 in your browser. Each time you refresh, the visit count increases. The count persists because the database uses a volume.

Essential Compose Commands

Start Services

# Start all services (attached — shows logs)
docker compose up

# Start in the background (detached)
docker compose up -d

# Start and rebuild images
docker compose up --build

# Start and rebuild in background
docker compose up -d --build

Stop Services

# Stop and remove containers
docker compose down

# Stop and remove containers + volumes (deletes data!)
docker compose down -v

# Stop without removing
docker compose stop

View Logs

# Logs for all services
docker compose logs

# Follow logs in real-time
docker compose logs -f

# Logs for one service only
docker compose logs -f app

Check Status

# List running services
docker compose ps

# Show running processes in each service
docker compose top

Execute Commands in a Running Service

# Open a shell in the app container
docker compose exec app bash

# Run a one-off command
docker compose exec db psql -U alex -d mydb

Rebuild

When you change your Dockerfile or source code:

# Rebuild and restart
docker compose up --build

# Only rebuild without starting
docker compose build

Using .env Files

Hardcoding passwords in docker-compose.yml is not ideal. You can use a .env file instead.

Create a .env file:

POSTGRES_USER=alex
POSTGRES_PASSWORD=secret
POSTGRES_DB=mydb

Update docker-compose.yml:

services:
  app:
    build: .
    ports:
      - "5000:5000"
    environment:
      - DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
    depends_on:
      - db

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

Docker Compose automatically reads the .env file in the same directory. The ${VARIABLE} syntax substitutes the values.

Add .env to your .gitignore and .dockerignore so secrets do not end up in version control or your image.

The depends_on Limitation

The depends_on option controls startup order. When you say app depends_on db, Compose starts db first. But it only waits for the container to start, not for the service inside to be ready.

A PostgreSQL container might take a few seconds to initialize. If your app tries to connect immediately, it might fail.

There are several ways to handle this:

Option 1: Retry in your application code. This is the most reliable approach. Add a retry loop that waits for the database.

Option 2: Use a wait script. Tools like wait-for-it or dockerize wait for a TCP port to be available before starting your app. You wrap your CMD with the wait script.

Option 3: Use depends_on with a health check. This is the cleanest approach.

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

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

With condition: service_healthy, Compose waits until the database passes its health check before starting the app.

Scaling Services

Docker Compose can run multiple copies of a service:

docker compose up -d --scale app=3

This starts 3 instances of the app service. All three share the same network and can connect to the database. This is useful for testing load balancing or running workers.

To see all running instances:

docker compose ps

Note: if you use ports: - "3000:3000", scaling fails because multiple containers cannot bind to the same host port. For scaling, use a reverse proxy like Nginx to distribute traffic.

Running One-Off Commands

Sometimes you need to run a command once — like running database migrations or creating test data. Use docker compose run:

# Run a command in the app service
docker compose run app python manage.py migrate

# Run a command and remove the container after
docker compose run --rm app python seed.py

The difference between exec and run:

  • exec — runs a command in an already running container
  • run — creates a new container, runs the command, and stops

This is useful for database tasks:

# Connect to the database shell
docker compose exec db psql -U alex -d mydb

# Run a SQL file
docker compose exec -T db psql -U alex -d mydb < seed.sql

# Create a database backup
docker compose exec db pg_dump -U alex mydb > backup.sql

Overriding Configuration

You can create a docker-compose.override.yml file for local development settings. Compose merges it automatically with docker-compose.yml:

docker-compose.override.yml:

services:
  app:
    volumes:
      - ./src:/app/src    # Bind mount for live code reloading
    environment:
      - DEBUG=true
  db:
    ports:
      - "5432:5432"       # Expose DB port for local GUI tools

This keeps your base docker-compose.yml clean for production. The override file adds development-specific settings like bind mounts and exposed ports.

Add docker-compose.override.yml to .gitignore if each developer has different settings. Or commit it if the team shares the same development configuration.

Compose File Versions

You might see version: "3.8" at the top of older Compose files. This is no longer required. Modern versions of Docker Compose (v2+) do not need the version field. You can safely omit it:

# Modern — no version field needed
services:
  app:
    image: nginx:alpine

If you see a Compose file with a version field, it still works. Docker Compose simply ignores it.

Viewing Service Resource Usage

To see how much CPU and memory your services are using:

docker stats

This shows live resource usage for all running containers. Press Ctrl+C to stop. It helps you identify if a service is using too much memory or CPU.

For a one-time snapshot instead of a live view:

docker stats --no-stream

Common Mistakes

1. Forgetting --build after code changes. If you change your source code and run docker compose up, Compose uses the old image. Always use docker compose up --build when you change code or the Dockerfile.

2. Using docker compose down -v accidentally. The -v flag removes volumes. If your database stores data in a volume, this deletes all your data. Use docker compose down (without -v) unless you specifically want to delete volumes.

3. Hardcoding secrets in docker-compose.yml. Use .env files for passwords and API keys. Add .env to .gitignore so secrets stay out of your repository.

What We Learned

In this tutorial, you learned:

  • Docker Compose manages multi-container applications with one file
  • docker-compose.yml defines services, ports, environment variables, and volumes
  • docker compose up starts all services, docker compose down stops them
  • depends_on controls startup order but does not wait for service readiness
  • .env files keep secrets out of your configuration
  • docker compose logs and docker compose exec help with debugging

What’s Next?

In the next tutorial, we will take a deeper look at volumes and networking. You will learn the difference between named volumes and bind mounts, how containers communicate over networks, and how to persist data properly.