Running a web app, a DB, and a cache each with docker run means memorizing long commands per container and wiring up networks by hand. The more common problem is that the app comes up before the DB is ready and dies on a connection error. Docker Compose declares the whole stack in one YAML file and brings it up with a single command.

The thing that actually trips you up with multiple containers is service startup order. The stack below starts the app only after the DB is ready. Running docker compose up confirms the order holds in the log. The output comes from a run on Docker Compose v5.1.1.

One stack, one file

This is a stack of nginx (app), PostgreSQL (DB), and Redis (cache). The healthcheck on the db service and the api waiting for that health state via depends_on ... condition: service_healthy are the parts that matter.

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: app
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d app"]
      interval: 3s
      timeout: 3s
      retries: 10

  cache:
    image: redis:7-alpine
    command: redis-server --save "" --appendonly no

  api:
    image: nginx:1.27-alpine
    depends_on:
      db:
        condition: service_healthy    # wait until db is "healthy"
      cache:
        condition: service_started    # cache only needs to have started
    ports:
      - "8088:80"

volumes:
  db-data:

The options in these service definitions do the following.

  • depends_on + condition: guarantees startup order. service_started only needs the container to come up; service_healthy requires the health check to pass before moving on.
  • healthcheck: a container being up does not mean the process inside is ready to accept requests. pg_isready periodically checks whether Postgres is accepting connections.
  • volumes: db-data stores the DB files so data survives even if the container is removed.
  • ports: maps host 8088 to container 80.

db and cache expose no ports to the host. Inside the default network Compose creates, api reaches them by service name, so there is no need to open them externally.

Watching the startup order

docker compose up -d

After the images are pulled, the final container-creation log shows the order.

 Container composedemo-cache-1  Started
 Container composedemo-db-1     Started
 Container composedemo-db-1     Waiting
 Container composedemo-db-1     Healthy
 Container composedemo-api-1    Starting
 Container composedemo-api-1    Started

Right after db is Started, it goes into Waiting, passes its health check to become Healthy, and only then does api move to Starting. The DB comes up before the app, as declared. Drop the condition and keep only depends_on, and Compose waits only for the container to come up, not for health, so the Waiting → Healthy step disappears.

docker compose ps shows db marked (healthy).

NAME                  IMAGE                SERVICE   STATUS
composedemo-api-1     nginx:1.27-alpine    api       Up 11 seconds
composedemo-cache-1   redis:7-alpine       cache     Up 22 seconds
composedemo-db-1      postgres:16-alpine   db        Up 22 seconds (healthy)

A service name is a hostname

Services in the same Compose file are attached to an automatically created network and find each other by service name. Resolving the names from inside the api container shows Compose’s built-in DNS mapping each service to its container IP.

docker compose exec api sh -c "getent hosts db; getent hosts cache"
172.18.0.2        db  db
172.18.0.3        cache  cache

So application config uses service names like db:5432 and cache:6379, not IPs. Container IPs change on every restart, but service names do not.

The host-mapped port works too. With api’s port 80 mapped to 8088, it is reachable from the host.

curl -s -o /dev/null -w "HTTP %{http_code}\n" http://localhost:8088/
# HTTP 200

Tear the stack down with one command. -v removes the volumes as well.

docker compose down -v
 Container composedemo-api-1    Removed
 Container composedemo-db-1     Removed
 Volume composedemo_db-data     Removed
 Network composedemo_default    Removed

Splitting config by environment

One stack still has parts that differ between development and production. Compose can layer multiple files, placing environment-specific overrides on top of a common base. A file listed later overrides the one before it.

# docker-compose.override.yml — development (source mount, debug port)
services:
  api:
    volumes:
      - ./src:/app
    ports:
      - "9229:9229"
    environment:
      - NODE_ENV=development
# dev: base + dev override
docker compose -f docker-compose.yml -f docker-compose.dev.yml up

# prod: base + prod override, in the background
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

If the file is named docker-compose.override.yml, plain docker compose up layers it on top of the base automatically.

Commands you reach for

CommandDescription
docker compose up -dStart the stack in the background
docker compose up --buildRebuild images, then start
docker compose psCheck service status (including health)
docker compose logs -f apiFollow a specific service’s logs
docker compose exec db psql -U postgres -d appRun a command inside a container
docker compose configValidate and print the final merged config
docker compose down -vStop the stack and remove volumes

When a merge looks wrong, docker compose config prints the final result of all merged files. Startup failures are almost always a service coming up before its dependency is ready, which the healthcheck + condition: service_healthy combination above solves.