Most real applications are not just one container. A typical web app has:

  • A web server (nginx)
  • An API server (your app)
  • A database (PostgreSQL)
  • A cache (Redis)

Running four separate docker run commands, connecting them manually, and managing their configuration is painful. Docker Compose solves this.

What is Docker Compose?

Docker Compose lets you define your entire application stack in a single YAML file. Then you start everything with one command:

docker compose up

Stop everything with:

docker compose down

That is the core idea. One file, one command.

Docker Compose v2 (Important)

There are two versions of Docker Compose:

  • docker-compose (v1) — the old Python-based CLI tool. Deprecated since 2021. Do not use it.
  • docker compose (v2) — a Go plugin built into Docker Engine. This is what you should use.

The difference is a hyphen: docker-compose vs docker compose.

If you see tutorials that use docker-compose up, they are outdated. The v1 CLI is no longer maintained.

Docker Compose v2 ships with Docker Desktop automatically. On Linux, it is included when you install Docker Engine from Docker’s official repository. If you installed Docker a different way on Linux, you may need to install the Compose plugin separately.

Your First docker-compose.yml

Create a file called docker-compose.yml in your project root:

# docker-compose.yml — Compose Specification v5
services:
  web:
    image: nginx:1.27
    ports:
      - "8080:80"

  api:
    build: .
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgres://alex:secret@db:5432/myapp
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:17
    environment:
      POSTGRES_USER: alex
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: myapp
    volumes:
      - db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U alex -d myapp"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  db_data:

This defines three services: web, api, and db.

Core Compose Concepts

services

Each services entry is one container. You define its image (or build context), ports, environment variables, and dependencies.

image vs build

Use image: when you want to pull a pre-built image from a registry:

services:
  db:
    image: postgres:17

Use build: when you want Docker Compose to build an image from a Dockerfile:

services:
  api:
    build: .          # looks for ./Dockerfile

Or with more options:

services:
  api:
    build:
      context: .
      dockerfile: Dockerfile.prod

ports

Maps host ports to container ports:

ports:
  - "8080:80"    # host:container
  - "5432:5432"

environment

Set environment variables inside the container:

environment:
  DATABASE_URL: postgres://alex:secret@db:5432/myapp
  APP_ENV: development

Or use a list format:

environment:
  - DATABASE_URL=postgres://alex:secret@db:5432/myapp

depends_on

Control startup order. This simple form just controls the order, not health:

depends_on:
  - db

This better form waits until the database is healthy before starting the API:

depends_on:
  db:
    condition: service_healthy

For service_healthy to work, the db service must define a healthcheck.

volumes

Named volumes persist data between container restarts:

services:
  db:
    volumes:
      - db_data:/var/lib/postgresql/data  # named volume

volumes:
  db_data:  # declare the volume here

Bind mounts map a host directory into the container (useful in development):

services:
  api:
    volumes:
      - ./src:/app/src   # host path : container path

Using .env Files

Never hardcode passwords in docker-compose.yml. Use a .env file instead:

# .env
POSTGRES_USER=alex
POSTGRES_PASSWORD=s3cr3t!
POSTGRES_DB=myapp

Then reference variables in your Compose file:

services:
  db:
    image: postgres:17
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}

Important: Add .env to your .gitignore. Never commit passwords to git.

echo ".env" >> .gitignore

Profiles: Start Optional Services

Compose profiles let you define services that only start when explicitly requested. Useful for debug tools, admin panels, or optional services:

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

  db:
    image: postgres:17

  pgadmin:
    image: dpage/pgadmin4
    profiles:
      - debug
    ports:
      - "5050:80"

Start everything including debug services:

docker compose --profile debug up

Start only the default services (api + db, no pgadmin):

docker compose up

Watch Mode: Auto-sync Code Changes

Compose Watch Mode is one of the most useful features for development. It watches your files and automatically syncs changes into running containers — no manual restart needed.

Requires: Docker Compose 2.22 or newer.

Add a develop.watch block to your service:

services:
  api:
    build: .
    develop:
      watch:
        - action: sync
          path: ./src
          target: /app/src
        - action: rebuild
          path: requirements.txt

There are four watch actions:

ActionWhat it doesBest for
syncCopy changed files into the running containerPython, Node.js, Ruby (no compile step)
rebuildRebuild the image and restart the containerGo, Rust, compiled languages
sync+restartSync files then restart the container processConfig file changes
restartRestart the container without syncing (since Compose 2.32)Environment variable changes

Start with watch mode:

docker compose watch

Or combine with up:

docker compose up --watch

Now when you save a Python file, it is instantly copied into the container. No restart needed.

Common Commands

# Start all services (build if needed)
docker compose up

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

# Build images and start
docker compose up --build

# Stop all services (keep volumes)
docker compose down

# Stop and remove volumes
docker compose down -v

# View running services
docker compose ps

# View logs from all services
docker compose logs

# Follow logs
docker compose logs -f

# Follow logs from one service
docker compose logs -f api

# Run a command in a running service
docker compose exec api bash

# Scale a service to 3 replicas
docker compose up --scale api=3

Complete 3-Tier App Example

Create a .env file with your secrets before running this example:

DB_PASSWORD=s3cr3t!

Here is a complete example of a web app with nginx, a Python API, and PostgreSQL:

# docker-compose.yml
services:
  web:
    image: nginx:1.27
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - api

  api:
    build:
      context: .
      dockerfile: Dockerfile
    environment:
      DATABASE_URL: postgresql://alex:${DB_PASSWORD}@db:5432/myapp
      APP_ENV: production
    env_file:
      - .env
    depends_on:
      db:
        condition: service_healthy
    develop:
      watch:
        - action: sync
          path: ./app
          target: /app/app
        - action: rebuild
          path: requirements.txt

  db:
    image: postgres:17
    environment:
      POSTGRES_USER: alex
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: myapp
    volumes:
      - db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U alex -d myapp"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  db_data:

The nginx.conf:

server {
    listen 80;
    location / {
        proxy_pass http://api:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

Notice that nginx uses api as the hostname. Docker Compose automatically creates a network where each service is reachable by its service name.

Common Mistakes

Still using docker-compose (v1)

The old docker-compose CLI is deprecated and no longer receives updates. Always use docker compose (v2 plugin). The difference is a hyphen in the command name.

Not using health checks with depends_on

depends_on without condition: service_healthy only controls the startup order. It does not wait for the database to be ready to accept connections. Your app can start before the database is ready and fail. Always add a healthcheck to your database service and use condition: service_healthy.

Hardcoding passwords in docker-compose.yml

Never put passwords directly in your Compose file. Use .env files with ${VARIABLE} syntax, and make sure .env is in .gitignore.

What’s Next?

Next: Docker Tutorial #6: Docker Volumes and Networking Explained