Skip to content
ioob.dev
Go back

Docker Part 7 — Multi-Container Orchestration with Docker Compose

· 5 min read
Docker Series (7/13)
  1. Docker for Beginners Part 1 — What Is Docker
  2. Docker for Beginners Part 2 — Images and Layers
  3. Docker for Beginners Part 3 — Writing a Dockerfile
  4. Docker for Beginners Part 4 — Container Lifecycle
  5. Docker for Beginners Part 5 — Volumes and Data Persistence
  6. Docker for Beginners Part 6 — Networking
  7. Docker Part 7 — Multi-Container Orchestration with Docker Compose
  8. Docker Part 8 — Slimming Images with Multi-Stage Builds
  9. Docker Part 9 — Registry: Where Do Images Live?
  10. Docker Part 10 — Container Security: Blocking Issues Before They Blow Up
  11. Docker Part 11 — BuildKit and Advanced Builds
  12. Docker Part 12 — Production Best Practices
  13. Docker Part 13 — Troubleshooting and Alternatives
Table of contents

Table of contents

What Compose Does

Let’s first look at the entire flow of what happens when you run docker compose up:

flowchart LR
    CMD["docker compose up"] --> PARSER["compose.yaml parsing"]
    PARSER --> NET["default network creation"]
    PARSER --> VOL["volume creation"]
    PARSER --> BUILD["image build / pull"]
    BUILD --> RUN1["web container"]
    BUILD --> RUN2["db container"]
    BUILD --> RUN3["cache container"]
    NET --> RUN1
    NET --> RUN2
    NET --> RUN3
    VOL --> RUN2
    RUN2 -.->|"healthcheck OK"| RUN1

Two key points. First, services automatically join the same network so they can call each other by service name. Second, if you declare dependencies and healthchecks, Compose handles the startup order for you.

Verifying the Installation

The latest Docker Desktop and Docker Engine include Compose v2 as a built-in plugin. Use docker compose (with a space) instead of the old docker-compose (with a hyphen).

docker compose version

If you see a version printed, you are all set. If your team still uses the docker-compose command, that is the EOL v1, and upgrading to v2 is recommended.

A Minimal compose.yaml

The official recommended filename is compose.yaml. The older docker-compose.yml is still supported, but both work. Here is the smallest configuration:

# compose.yaml
services:
  web:
    image: nginx:1.27-alpine
    ports:
      - "8080:80"

Under services, we defined one service named web. The image is nginx, and host port 8080 is mapped to container port 80. Start it from the directory containing this file:

docker compose up -d

-d is detached mode, meaning it runs in the background. Open http://localhost:8080 in a browser and the default Nginx page appears. To shut it down, run docker compose down.

Three Pillars — services, networks, volumes

A Compose file is structured around three top-level keys. Understanding these three is the foundation; everything else is detail.

services:   # Definition of each individual container
  ...
networks:   # Networks to connect services
  ...
volumes:    # Volumes for data persistence
  ...

Let’s look at a simple web + DB + cache setup as an example:

services:
  web:
    image: myapp:latest
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgres://app:secret@db:5432/app
      REDIS_URL: redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    networks:
      - backend

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

  cache:
    image: redis:7-alpine
    networks:
      - backend

networks:
  backend:

volumes:
  db-data:

Reading through it casually reveals the story. web opens port 3000 and depends on the DB and cache. db uses a postgres image with a volume attached so data survives restarts. All three join the same backend network.

The key thing to notice is db:5432 and cache:6379 in the environment section. Service names are used directly in the hostname position. Within the network Compose creates, service names function as DNS names. No need to know IPs.

depends_on and healthcheck — The Truth About Startup Order

When first using Compose, it is easy to assume depends_on guarantees startup order. To be precise, it only guarantees the order in which containers start. It does not guarantee that “the DB process is ready to accept queries.”

That is why condition is added:

sequenceDiagram
    participant Compose
    participant DB
    participant Web
    Compose->>DB: Start container
    DB-->>Compose: started
    loop Healthcheck every 5s
        Compose->>DB: pg_isready
        DB-->>Compose: ok?
    end
    DB-->>Compose: healthy
    Compose->>Web: Start container (dependency satisfied)

For services like databases that need preparation time, always define a healthcheck and use service_healthy. Otherwise, the web starts first, fails to connect to the DB, and enters a restart loop.

Networks — Separation and Exposure

Putting everything on one network works, but as scale grows, separating them is safer. For example, if the DB should be inaccessible from the outside and only the web should be exposed to the reverse proxy network:

services:
  proxy:
    image: traefik:v3
    networks:
      - edge

  web:
    image: myapp:latest
    networks:
      - edge      # Communicates with proxy
      - backend   # Communicates with db

  db:
    image: postgres:16-alpine
    networks:
      - backend   # Only on backend

networks:
  edge:
  backend:
    internal: true   # Blocks external internet access

Setting internal: true disconnects the network from external routing. Since a DB has no reason to fetch anything from the internet, this is a safe default.

Volumes — named vs bind

There are two approaches to volumes, with different use cases:

services:
  db:
    image: postgres:16-alpine
    volumes:
      - db-data:/var/lib/postgresql/data   # named volume
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro   # bind mount

volumes:
  db-data:

In development environments, source code is bind-mounted so file changes are reflected immediately, while data is managed with named volumes.

.env and Variable Substitution

Passwords and environment-specific values should not be hardcoded in the file. Place a .env file in the same directory and Compose reads it automatically:

# .env
POSTGRES_PASSWORD=secret
APP_PORT=3000
services:
  web:
    image: myapp:latest
    ports:
      - "${APP_PORT}:3000"
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}

.env must be added to .gitignore. A single commit mistake and the password goes to the remote repo.

profiles — Turning On Only What You Need

Developers might only need the app + DB, but the QA environment might require Kafka, Mailhog, and more. Assign profiles to services so they only start in the desired scenarios:

services:
  web:
    image: myapp:latest
  db:
    image: postgres:16-alpine

  mailhog:
    image: mailhog/mailhog
    profiles: ["dev"]

  kafka:
    image: confluentinc/cp-kafka:7.6.0
    profiles: ["qa"]

The default startup includes only web and db:

docker compose up -d                        # web, db only
docker compose --profile dev up -d          # + mailhog
docker compose --profile qa up -d           # + kafka

Running up without options does not touch services with profiles. They are only brought up when explicitly requested.

Environment-Specific Overrides

You often want to share the same base config across development/staging/production but override a few settings. Compose defaults to merging compose.yaml + compose.override.yaml. You can also explicitly merge multiple files:

# compose.yaml — shared
services:
  web:
    image: myapp:latest
    environment:
      LOG_LEVEL: info
# compose.dev.yaml — development overrides
services:
  web:
    build: .
    volumes:
      - ./src:/app/src
    environment:
      LOG_LEVEL: debug
    ports:
      - "3000:3000"
# compose.prod.yaml — production overrides
services:
  web:
    restart: always
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M

When running, list multiple files with -f and they are merged in order:

docker compose -f compose.yaml -f compose.dev.yaml up -d
docker compose -f compose.yaml -f compose.prod.yaml up -d

For duplicate settings on the same service key, the later file wins. Arrays are overwritten by default, while maps like environment variables are merged. Keeping this in mind, collisions are rare.

Frequently Used Commands

When using Compose, you will type these commands hundreds of times:

# Full start / stop
docker compose up -d
docker compose down

# Delete volumes too (data will be lost — caution)
docker compose down -v

# Specific service only
docker compose up -d web
docker compose restart web

# View logs
docker compose logs -f web

# Enter a container
docker compose exec web sh

# Rebuild images on startup
docker compose up -d --build

# View the final merged config (override merge result)
docker compose config

The last one, config, is often forgotten but very useful for debugging. It lets you preview what the final result looks like after merging multiple files.

Where We Stand

We have covered spinning up web + DB + cache with a single Compose file, separating networks, and configuring per-environment overrides. In practice, you just add monitoring (Prometheus, Grafana) or message brokers (Kafka, RabbitMQ) — the skeleton stays the same.


In the next part, we cover multi-stage builds for shrinking image sizes and speeding up builds. We will show firsthand how small the final image can get when you separate build artifacts from the runtime.

Part 8: Multi-Stage Builds


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Docker for Beginners Part 6 — Networking
Next Post
Docker Part 8 — Slimming Images with Multi-Stage Builds