The problem Compose solves

A real application is rarely one container. A web API needs a database, the database needs a volume so its data survives a restart, and then you add a Redis cache and a background worker. That is four docker run commands, each with its own flags for ports, volumes, environment variables and a shared network. In the right order. Every time you sit down to work.

Docker Compose replaces those commands with one file and one command. You describe the containers and how they connect in a file called compose.yaml, and docker compose up starts all of them. If images and docker run are new to you, read what Docker is and how containers work first.

What the compose file describes

A compose file has a handful of top-level keys. The one you always use is services — each service is a container Compose will run.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
services:
  api:
    build: .
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: postgres://app:secret@db:5432/app
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:18
    volumes:
      - db-data:/var/lib/postgresql/data
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: app
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
      timeout: 3s
      retries: 5

volumes:
  db-data:

Two containers. api is built from the Dockerfile in the current directory and publishes port 8000. db runs the official postgres:18 image and keeps its data in a named volume, so the data is still there when the container is recreated.

Two details do the real work here:

  • api reaches db at the hostname db. Compose puts every service on a shared network and registers each service name as a DNS name. No IP addresses, no legacy --link. DATABASE_URL points at db:5432 and it resolves.
  • depends_on with condition: service_healthy holds api back until Postgres actually answers. Plain depends_on waits only for the container to start, not for the database process inside it to accept connections — that gap is the usual reason an app throws “connection refused” on the first boot.

The three top-level keys: services, volumes, networks

Three top-level keys map straight onto Docker concepts you already know:

KeyWhat it definesDone by hand with
servicesthe containers to rundocker run
volumesnamed volumes for data that must persistdocker volume create
networksnetworks between servicesdocker network create

You rarely declare networks. Compose creates one network per project and attaches every service to it, which is why the Postgres example needed no network config at all. Declare networks explicitly only when you want to keep groups of services from reaching each other:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
services:
  api:
    build: .
    networks: [frontend, backend]
  db:
    image: postgres:18
    networks: [backend]        # not reachable from frontend
  proxy:
    image: caddy:2
    networks: [frontend]

networks:
  frontend:
  backend:

The commands you actually use

Run these from the directory that holds compose.yaml.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# Start everything, logs streamed to the terminal
docker compose up

# Start in the background
docker compose up -d

# Rebuild images that have a build section, then start
docker compose up --build

# Stop and remove containers and networks (named volumes are kept)
docker compose down

# Same, and delete named volumes too
docker compose down -v

# What is running for this project
docker compose ps

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

# One-off command in a fresh container
docker compose run --rm api python manage.py migrate

# Shell into a container that is already running
docker compose exec api bash

up and down are the pair you type most. up is safe to run repeatedly: after you edit the file, it recreates only the services whose config changed and leaves the rest alone.

What docker compose up prints on the first run

Run the Postgres example above and you get roughly this:

1
2
3
4
5
[+] Running 4/4
 âś” Network app_default    Created
 âś” Volume "app_db-data"   Created
 âś” Container app-db-1      Healthy
 âś” Container app-api-1     Started

The names are prefixed with the project name (the directory name by default) and suffixed with a number, because Compose is ready to run more than one replica of a service. app-db-1 reports Healthy before app-api-1 starts — that is the condition: service_healthy doing its job.

How to keep passwords out of the compose file

Compose reads a file named .env in the project directory and substitutes ${VAR} references in the compose file:

1
2
3
4
5
services:
  db:
    image: postgres:18
    environment:
      POSTGRES_PASSWORD: ${DB_PASSWORD}
1
2
# .env  — add this file to .gitignore
DB_PASSWORD=secret

Keep .env out of version control and commit a .env.example with blank or dummy values instead. For anything genuinely sensitive in a deployed environment, use Docker secrets rather than environment variables.

Optional services with profiles

Not every service should start every time. A profiles entry keeps a service idle unless you ask for it:

1
2
3
4
5
6
7
8
9
services:
  api:
    build: .
  db:
    image: postgres:18
  seed:
    build: .
    command: python manage.py seed_demo_data
    profiles: [tools]

docker compose up starts api and db and ignores seed entirely. docker compose --profile tools run --rm seed runs the seeder when you ask for it. Use profiles for anything one-shot: seeders, migrations, a debugging shell, a load generator you only want during a test.

Compose v2 vs the old docker-compose

If a guide tells you to run docker-compose with a hyphen, it predates 2023. That was v1, written in Python, now end of life. The current tool is docker compose as a subcommand of the Docker CLI; it comes with Docker Desktop and with the Compose plugin package for Docker Engine.

You will also meet this line at the top of older files:

1
version: "3.8"   # delete it

The version key has no effect in Compose v2. Compose validates against the current Compose Specification and warns you when the key is present. Remove it and the warning goes away.

The default file name moved too: compose.yaml is current, docker-compose.yml still works, and Compose looks for either.

Live reload during development

docker compose watch (Compose 2.22 and later) updates containers as you edit. Add a develop block to the service:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
services:
  api:
    build: .
    develop:
      watch:
        - action: sync
          path: ./src
          target: /app/src
        - action: rebuild
          path: ./requirements.txt

sync copies changed files straight into the running container, so a code edit shows up without a rebuild. rebuild triggers a full image rebuild when a file that affects the build changes, like a dependency lock file. Start it with docker compose watch, or add --watch to up.

When Compose is the wrong tool

Compose runs containers on one machine. That is the boundary, and most of what people find missing in Compose is on the other side of it.

It fits local development, automated tests in CI, and small single-host deployments. It has no notion of scheduling containers across several servers, replacing one when a node dies, rolling updates gated on health checks, or autoscaling.

Those belong to an orchestrator: Kubernetes, or Docker Swarm if you want something lighter. The two are not in competition. A compose file is usually the rough draft that later becomes a set of Kubernetes manifests, and plenty of teams keep using Compose locally long after production has moved to a cluster.

Podman users get a compatible path here too: podman compose and podman-compose read the same file format, with the caveats covered in Docker vs Podman.

Common mistakes

  • Secrets committed in compose.yaml. The file lives in Git. Use .env (gitignored) or Docker secrets.
  • Trusting depends_on to wait for readiness. On its own it waits for the container to start, not for the service to accept connections. Pair it with a healthcheck and condition: service_healthy.
  • Setting container_name. It stops you running more than one copy of the project and breaks docker compose up --scale. Let Compose name containers.
  • Bind-mounting over an installed dependency directory. Mounting your project folder into a Node or Python container can shadow the node_modules or virtualenv created during the build. Mount source subdirectories, or put an anonymous volume on the dependency path.

How to move an existing app to Compose

Take the app you currently start with a shell script full of docker run lines and move it into a compose.yaml one service at a time. Bring it up, read the logs, fix what breaks, add the next service. Give a healthcheck to anything other services depend on — that is the step people skip, and it is the one that stops the “connection refused” on a cold start.

You will know it worked when docker compose down -v && docker compose up rebuilds the entire environment from nothing and the app comes back the same way every time.