OnCallReady

Lesson 11.31 · Docker Runtime & Networking · 16 min read

Docker Compose: several containers as one project

In plain words

Imagine setting up a birthday party. You could phone the baker, the DJ and the balloon person one by one, telling each exactly when to come and where to park. Or you write one party plan on a single sheet, hand it to an organiser, and they call everyone in the right order, with the right instructions, and clean up afterwards.

compose.yaml is that plan for containers. It lists services, like db, app and web, with their images, env vars, volumes, ports and dependencies. docker compose up -d creates a network where they find each other by service name, the volumes, and the containers in order. depends_on with condition: service_healthy waits for the database to be ready, and docker compose down removes it all again.

Why this matters

A real app is rarely one container: an API, its database, a proxy in front. Typing three long docker run commands, in the right order, with the right network and volumes, every time, is how mistakes happen. Docker Compose writes the whole set down in one file and starts it with one command.

What you need to know already: everything in this chapter so far - especially user-defined networks and DNS (11.15), volumes (11.22), restart policies (11.7); HEALTHCHECK (10.44); YAML syntax from netplan (8.11); ${VAR:-default} and ${VAR:?msg} from bash (6.12).

One file for the whole stack

Compose reads compose.yaml in the current directory. Each entry under services: is one Compose service - one kind of container, with what you would otherwise pass to docker run:

# compose.yaml
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}
      POSTGRES_DB: orders
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      retries: 10

  app:
    build: .
    environment:
      DB_HOST: db
    env_file: app.env
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

  web:
    image: nginx:1.27
    ports:
      - "8080:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on: [app]

volumes:
  pgdata:

Reading it against docker run:

# in a project directory with that compose.yaml (the compose mission builds one)
docker compose up -d
[+] Running 5/5
 ✔ Network orders_default      Created                          0.1s
 ✔ Volume "orders_pgdata"      Created                          0.0s
 ✔ Container orders-db-1       Healthy                          6.2s
 ✔ Container orders-app-1      Started                          6.4s
 ✔ Container orders-web-1      Started                          6.5s

docker compose up -d creates everything and starts it in the background (-d as in docker run -d). Everything is named after the project, which defaults to the directory name (-p name, COMPOSE_PROJECT_NAME or a top-level name: override it):

network     orders_default                 a user-defined bridge: DNS works
volume      orders_pgdata
containers  orders-db-1  orders-app-1  orders-web-1     project-service-number
images      orders-app                     for services with build:

Each container joins the project network with its service name as a DNS alias, so app reaches the database as db:5432 - the container port, not a published one (11.15). version: "3.8" at the top of old files is obsolete; Compose v2 ignores it and prints the attribute \version\ is obsolete, it will be ignored.

depends_on is ordering, not readiness

depends_on: [db] only means "start db's container first". Postgres takes a few seconds to initialise; an app that connects immediately fails with Connection refused and exits - or crash-loops with a restart policy. Waiting until it is ready needs a healthcheck on the dependency and a condition:

depends_on:
  db:
    condition: service_healthy     # wait until db's healthcheck passes

Other conditions: service_started (the default), service_completed_successfully (for a one-off job, like a database migration that must finish first). If the dependency never becomes healthy, up fails with dependency failed to start: container orders-db-1 is unhealthy. The application should still retry its connections - dependencies also restart later, when Compose is not there to order anything.

Two different kinds of "env"

.env (next to compose.yaml)   feeds ${VAR} INTERPOLATION in the compose file itself
env_file: app.env             variables INJECTED into the container's environment
environment:                  variables injected, written inline

Interpolation = replacing ${VAR} in the compose file with a value before Compose uses it, like bash expanding $VAR in a command line (6.12).

# same project
cat .env
DB_PASSWORD=s3cret
docker compose config | grep POSTGRES_PASSWORD
      POSTGRES_PASSWORD: s3cret

docker compose config prints the file after interpolation - the fastest way to see what Compose will really do. Missing variables are a warning and an empty string:

WARN[0000] The "DB_PASSWORD" variable is not set. Defaulting to a blank string.

unless you use ${VAR:?message} (an error) or ${VAR:-default} - the same syntax as bash. Shell environment variables override .env. And never commit a .env that holds real credentials to git; commit a .env.example with placeholder values.

Profiles

  adminer:
    image: adminer
    profiles: [debug]

A service with profiles: is skipped unless that profile is switched on: docker compose --profile debug up -d (or COMPOSE_PROFILES=debug). Handy for debug tools (adminer is a web UI for databases), load generators and one-off jobs that live next to the stack.

The everyday commands

docker compose up -d                 create/update and start everything
docker compose up -d --build         rebuild images for build: services first
docker compose ps                    status of this project's containers
docker compose logs -f app           logs, prefixed by service (-f follow)
docker compose exec db psql -U postgres     a command in a running service (psql = postgres shell)
docker compose run --rm app ./migrate       a one-off container for a service
docker compose restart app           restart (does NOT pick up config changes)
docker compose down                  stop + remove containers and the network
docker compose down -v               ... and the named volumes: THE DATA

Note exec and logs take the service name (db), not the container name (orders-db-1).

up -d is idempotent - running it again changes nothing unless the file changed: edit the file, run it again, and Compose recreates only the services whose configuration changed. restart does not - it restarts the old containers with the old config (11.7). And down keeps volumes; down -v deletes them, which is the right way to reset a dev database and the wrong command to run by habit.

Where Compose stops

Compose runs a project on one host. No spreading containers across machines, no rolling updates with health gates, no self-healing beyond restart policies. It is the right tool for local development, integration tests in build pipelines and small single-host deployments.

Later (Ch 15): Kubernetes runs the same ideas - services, DNS by name, health-gated start, injected env, named volumes - across many machines.

What you can now do

Why it helps

Compose is where you will spend a lot of practical time: local development stacks, integration tests in build pipelines, small single-host deployments, and reproducing a bug with the exact service versions from production. Reading a teammate's compose file and spotting the problems is a regular review task: depends_on without a healthcheck, a password committed in .env, a database port published to everyone, restart expected to apply config changes.

The command semantics prevent real accidents: up -d recreates only what changed, restart does not apply edits, and down -v deletes the database. docker compose config shows what the interpolated file really means. And a compose file in the repository is documentation that runs: a new teammate gets the whole stack with one command instead of a page of docker run lines.

FAQ

Why does my app fail at start even with depends_on: [db]?

The short form of depends_on only orders container starts: db's container starts first, but postgres needs a few seconds to initialise and accept connections. An app that connects immediately gets Connection refused. Add a healthcheck to db, for example pg_isready, and use depends_on: db: condition: service_healthy. The app should still retry its connections, because dependencies also restart later, when Compose is not ordering anything.

What is the difference between .env and env_file?

.env next to compose.yaml is read by Compose itself for ${VAR} interpolation in the compose file; its values only reach containers if the file references them. env_file: app.env and environment: inject variables into the container's environment. Shell variables override .env. docker compose config shows the file after interpolation, which settles most confusion. Commit .env.example, never a .env with real credentials.

Why did docker compose restart not pick up my change?

restart stops and starts the existing containers with their existing configuration. Changes in compose.yaml, like new env vars, ports, volumes or image tags, only take effect when the container is recreated. Run docker compose up -d again: it compares the desired configuration with what is running and recreates only the services that changed. For services with build:, add --build so the image itself is rebuilt too.

Does docker compose down delete my database?

docker compose down stops and removes the containers and the project network, but keeps named volumes, so the data survives and comes back on the next up. docker compose down -v also removes the project's named volumes, which deletes the database. That is the right way to reset a dev environment and the wrong thing to type by habit. Check with docker volume ls before and after if unsure.

Can I use Compose in production?

For small single-host deployments, yes, and many teams do: with restart: unless-stopped, healthchecks, log rotation and pinned image versions it is reliable. What it cannot do is run across several machines, move containers away from a failed host, do rolling updates gated by health, or add copies under load. Once you need that, you need an orchestrator that manages many hosts, a later topic. Compose remains the standard for local development and CI integration tests.

In an interview Junior

What is Docker Compose, and how do you make a service start only after its database is ready?

Compose describes several containers as one project in compose.yaml: each service is what you would pass to docker run (image, environment, volumes, ports, restart, healthcheck). docker compose up -d creates a project network (service names resolve by DNS, so the app reaches db:5432), the volumes and the containers. It runs on one host: for local development, tests and small deployments.

depends_on: [db] only orders the start. To wait for readiness, give the database a healthcheck and use a condition:

depends_on:
  db:
    condition: service_healthy

with healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] on db. Still not enough on its own: the app should retry its connections, because the database can restart later when Compose is not ordering anything.

Also asked: What is the difference between the .env file and env_file in Compose? · What does docker compose down -v do? · Why does docker compose restart not apply changes from the compose file?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.