The last article ran two containers by hand: create a network, create a volume, run the database, run the app, remember the right flags every single time. That works, but it does not scale past a couple of containers, and it is impossible to hand to a teammate as anything other than a wall of commands to copy carefully. Docker Compose exists to fix exactly that.

One file, one application

A compose.yaml file describes every container your application needs—called services—along with the networks and volumes connecting them, in one declarative file:

services:
  web:
    build: .
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgres://postgres:devpassword@db:5432/postgres
    depends_on:
      - db

  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: devpassword
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

That one file replaces every command from the last article's stateful example: the network is created automatically, web can reach db by name without any extra configuration, and pgdata persists exactly the way the named volume did before.

docker compose upweb servicedb servicepgdata volume

The commands you actually use

docker compose up          # build/start everything, attached
docker compose up -d       # same, but detached
docker compose down        # stop and remove containers and the default network
docker compose logs -f web # follow one service's logs
docker compose ps          # what's running, per service

docker compose up reads compose.yaml in the current directory, creates a network for the whole project automatically, builds any service with a build: key, pulls any service with an image: key, and starts everything in the right order based on depends_on.

depends_on solves ordering, not readiness

depends_on: [db] guarantees Compose starts the db container before web. It does not guarantee Postgres has finished initializing and is ready to accept connections by the time web starts—a container can be "started" long before the process inside it is actually ready to do useful work. For real projects, pair depends_on with a retry loop in the application's own startup code, or a healthcheck: on the db service and a depends_on: { db: { condition: service_healthy } } on web. A beginner setup can usually get away without this; a real one should not skip it.

Why this is the natural next step from docker run

Everything in compose.yaml maps directly to flags you already know from docker run:

Compose keyEquivalent docker run flag
image: / build:the image argument
ports:-p
environment:-e
volumes:-v
depends_on:(manual ordering, done by hand before)

Nothing here is a new concept. Compose is a way to write down the commands you were already typing, so the whole application starts the same way every time, for every person on the team, without anyone needing to remember the right combination of flags.

Common mistakes

  • Assuming depends_on means "ready," not just "started." A database container can be running long before the database inside it accepts connections.
  • Putting real secrets directly in compose.yaml. Use an .env file (Compose reads one automatically) or environment: - MY_VAR referencing a value set outside the file, and keep compose.yaml itself safe to commit.
  • Forgetting docker compose down removes containers, not named volumes. Data in a named volume survives down; add -v if you actually intend to delete it too, and be sure that is what you want.
  • Rebuilding by hand instead of using Compose. docker compose up --build rebuilds images defined with build: before starting—no need to run docker build separately.

Practical checklist

  • My project's services, networks, and volumes are defined in one compose.yaml, not a list of manual commands.
  • I can start, stop, and tail logs for the whole application with docker compose up, down, and logs -f.
  • I understand that depends_on controls start order, not readiness.
  • Real secrets are supplied through an .env file or the environment, not hardcoded into compose.yaml.
  • I know that docker compose down keeps named volumes unless I explicitly add -v.

Next in the series: Docker vs. Podman: What Actually Changes. We will put the second engine this series promised side by side with everything you have learned so far.