Skip to main content

Lesson: Multi-Service Apps with Compose

What you'll learn

  • Explain what Docker Compose is and why a YAML file beats a pile of docker run commands.
  • Read and write a compose.yaml with services, networks, volumes, and environment.
  • Use up, down, logs, and ps to run a whole stack with one command.
  • Control start order and readiness with depends_on (and its limits).

By the end you can define a multi-container application as code and bring it up or tear it down reproducibly — the everyday workflow for local dev and small deployments.

The lesson

1. Why Compose exists

A real app is rarely one container. A web API needs a database; maybe a cache; maybe a worker. Starting each by hand — remembering every -p, every -e, every --network — is error-prone and impossible to share. Docker Compose lets you describe the whole stack in a single declarative file, compose.yaml, and manage it with one command. It is infrastructure as code for your local container stack: commit the file to git and a teammate gets the identical setup.

Compose builds directly on what you already know — it just creates the containers, the user-defined network (Chapter 3), and the volumes for you.

2. The shape of a compose.yaml

A minimal two-service stack — a web API and a Postgres database:

# compose.yaml
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: <REDACTED>
      POSTGRES_DB: appdb
    volumes:
      - db-data:/var/lib/postgresql/data
    networks:
      - appnet

  api:
    image: 10.100.100.6/myorg/api:sha-abc123   # from the lab registry
    ports:
      - "8080:8080"
    environment:
      DATABASE_URL: postgres://app:<REDACTED>@db:5432/appdb
    depends_on:
      - db
    networks:
      - appnet

networks:
  appnet:

volumes:
  db-data:

Note @db:5432 in the URL: Compose puts both services on the appnet network, so the API reaches the database by its service name db (the DNS trick from Chapter 3).

3. The four building blocks

services — each entry is one container role. Common keys:

  • image: use a prebuilt image, or build: ./dir to build from a local Dockerfile.
  • ports: publish to the host, same HOST:CONTAINER syntax as -p.
  • environment: set env vars (or load a file with env_file:).
  • restart: unless-stopped to auto-restart on crash/reboot.

networks — declare named networks; list them under each service. If you omit networks entirely, Compose still creates one default network and joins every service to it — so name-based discovery works out of the box.

volumesnamed volumes persist data beyond a container's life. Containers are ephemeral (Chapter 1); a volume is where databases keep data that must survive a restart. Two forms:

volumes:
  - db-data:/var/lib/postgresql/data   # named volume (managed by Docker)
  - ./config:/etc/app:ro               # bind mount: host folder -> container (read-only)

environment — configuration without rebuilding the image. Keep secrets out of git; in real setups use an .env file or Docker secrets rather than inline passwords.

4. The everyday commands

Run all of these from the folder containing compose.yaml:

docker compose up -d        # create & start the whole stack in background
docker compose ps           # what's running in this stack
docker compose logs -f      # tail logs from ALL services
docker compose logs -f api  # tail just one service
docker compose exec api sh  # shell into a running service
docker compose down         # stop & remove containers + the network
docker compose down -v      # ALSO delete named volumes (destroys data!)
docker compose pull         # fetch newer images
docker compose up -d --build  # rebuild images that use `build:` then start

Mind the difference: down keeps your named volumes (data safe); down -v wipes them. The -d flag means detached (background), exactly as with docker run.

        docker compose up -d
   +---------------------------------------+
   |  network: appnet (auto-created)       |
   |    [api]:8080  --DNS-->  [db]:5432     |
   |       |                     |         |
   |     -p 8080                volume db-data
   +---------------------------------------+

5. Controlling start order: depends_on

depends_on makes Compose start db before api. But beware: by default it only waits for the container to start, not for the database to be ready to accept connections. A database process can take a few seconds to initialise after its container starts.

To wait for genuine readiness, combine depends_on with a healthcheck and the condition: service_healthy:

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: <REDACTED>
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 5

  api:
    image: 10.100.100.6/myorg/api:sha-abc123
    depends_on:
      db:
        condition: service_healthy   # wait until db reports healthy

Even with this, well-written apps should still retry their database connection on startup — networks and dependencies are never guaranteed instantly. depends_on is a convenience, not a hard guarantee.

6. File naming and tips

  • The modern filename is compose.yaml (older guides say docker-compose.yml; both still work). Use compose.yaml.
  • Pin image tags (e.g. postgres:16, or a sha tag from the lab registry) — never rely on latest, which silently changes.
  • One project per folder; Compose names everything after the folder, so two folders give two independent stacks.
  • docker compose config prints the fully-resolved file — great for catching YAML/indentation mistakes before you run.

7. Putting it together

The workflow you will use constantly: edit compose.yaml -> docker compose up -d -> check docker compose ps and docker compose logs -f -> make changes -> docker compose up -d again (Compose recreates only what changed) -> docker compose down when finished. That loop is the heart of local container development, and it scales straight into Assignment 2.

Dig deeper

Search terms

  • docker compose yaml tutorial beginner
  • docker compose services networks volumes explained
  • docker compose up down logs commands
  • docker compose depends_on service_healthy healthcheck
  • docker compose named volume vs bind mount
  • docker compose environment env_file

Check yourself

  1. What problem does Compose solve compared with running many docker run commands?
  2. In the example, how does the api service reach the database, and why does the name db work?
  3. What is the difference between docker compose down and docker compose down -v?
  4. Why is plain depends_on not enough to guarantee the database is ready, and what makes it wait for real readiness?
  5. What is the difference between a named volume and a bind mount, and when would you use each?