Skip to main content
RunBook Academy

Docker & ContainersIX Β· Docker ComposeCompose primitives

Services, networks, volumes β€” the three primitives

Foundation⏱ ~28 mindocker

What you'll learn

  • Write a minimal compose.yml and read what each primitive becomes on the daemon
  • Predict which edits to a Compose file force a container replacement
  • Use `internal:` and per-service network attachment to build a real trust boundary
  • Recognise the anti-patterns that survive review because they still work

Prerequisites

Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-12

Not yet marked complete on this device.

A compose.yml describes a multi-container application. Three primitives do most of the work: services, networks and volumes. Other primitives β€” configs and secrets β€” build on them.

A minimal example

name: shop

services:
  web:
    image: nginx:1.27
    ports:
      - "8080:80"
    volumes:
      - web-data:/usr/share/nginx/html
    networks:
      - app-net
    depends_on:
      api:
        condition: service_started

  api:
    image: myorg/api:1.0.0
    environment:
      DATABASE_URL: postgres://db:5432/app
    networks:
      - app-net
      - db-net
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: app
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
    volumes:
      - db-data:/var/lib/postgresql/data
    networks:
      - db-net
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 30s

networks:
  app-net:
  db-net:
    internal: true

volumes:
  web-data:
  db-data:

secrets:
  db_password:
    file: ./secrets/db_password.txt

The shape matters more than the detail. web is on app-net only, so it cannot reach the database at all β€” not β€œshould not”, cannot, because Docker’s embedded DNS will not resolve db for a container that shares no network with it. api bridges the two. db-net is internal: true, which means the daemon installs no default route out of it: the database can talk to the API and to nothing else, including the internet.

That last property is the cheapest containment control in Compose and almost nobody sets it.

What each primitive becomes

Read-only / Safeverify the boundary
# Should fail: web is not on db-net, so the name does not resolve
docker compose exec web getent hosts db || echo 'OK: db is not resolvable from web'

# Should succeed: api bridges both networks
docker compose exec api getent hosts db

# Confirm db-net really has no route out
docker network inspect shop_db-net --format '{{.Internal}} {{.Driver}} {{(index .IPAM.Config 0).Subnet}}'

A test that can fail is the point. β€œWe put them on separate networks” is a claim; getent hosts db returning nothing from web is evidence.

The reconciliation: what up actually does

This is the part that separates people who use Compose from people who understand it. docker compose up on a stack that is already running is not a restart and not a no-op. It is a diff.

The corollary catches people out. Changes that force a replacement include anything create-time: image, command, entrypoint, environment, ports, volumes, networks, user, labels, resource limits, restart policy. Changes that do not force a replacement include editing the contents of a file that a bind mount points at, or editing a .env value that the service does not actually reference.

Read-only / Safedry run
docker compose --dry-run up -d

# The hash currently on disk, per container
docker compose ps -q | xargs -r docker inspect \
--format '{{index .Config.Labels "com.docker.compose.service"}} {{index .Config.Labels "com.docker.compose.config-hash"}}'

Two flags override the diff when you need them to. --force-recreate replaces every container even when nothing changed β€” useful after a manual docker exec that dirtied a writable layer. --no-recreate leaves existing containers alone even when the spec changed, which is occasionally right during an incident and always wrong in a deploy pipeline.

The service keys worth knowing

services:
  NAME:
    image: IMAGE          # or a digest pin: IMAGE@sha256:...
    build: .              # build context, if this service is built locally
    pull_policy: missing  # always | never | missing | build | daily | weekly
    command: [...]        # override CMD
    entrypoint: [...]     # override ENTRYPOINT
    environment: {}       # env vars
    env_file: .env        # load from file
    ports: ["8080:80"]    # host:container publish
    expose: ["9090"]      # documented, not published
    volumes: [...]        # mounts
    networks: [...]       # networks to attach
    depends_on: {}        # ordering plus health conditions
    healthcheck: {}       # readiness signal
    restart: unless-stopped
    user: "1001:1001"
    read_only: true       # read-only root filesystem
    tmpfs: ["/tmp"]       # writable scratch when read_only is set
    cap_drop: [ALL]
    security_opt: ["no-new-privileges:true"]
    stop_grace_period: 30s
    profiles: [...]
    deploy:
      resources:
        limits:
          cpus: "2.0"
          memory: 1g
          pids: 200

Anti-patterns that still work

Each of these produces a stack that comes up cleanly, which is why they survive review.

  • image: myapp:latest. The container is bound to whatever digest latest resolved to at create time, so the running container and the tag drift apart silently. Pin a version, and pin a digest for anything you would have to explain in an incident review.
  • Short-syntax depends_on. It waits for the container to start, not to be ready. This is the single most common Compose defect; it has its own lesson.
  • No healthcheck. Without one, service_healthy is unavailable, --wait degrades to β€œis it running”, and your reverse proxy has nothing to route on.
  • No resource limits. One container with a memory leak takes the host down, and the OOM killer picks its victim by score, not by whose fault it was.
  • Every service on one network. Convenient, and it means a compromised frontend can reach the database directly. Segment, then set internal: true on the data network.
  • Mounting the Docker socket. A container with /var/run/docker.sock can start a privileged container mounting the host root filesystem. It is host root, spelled differently.

Knowledge check

Knowledge check Β· 5 questions

  1. Q1. In a Compose file, `networks:` at the top level:

  2. Q2. Named volumes declared in Compose persist across `docker compose down`.

  3. Q3. You edit an nginx.conf that is bind-mounted into a container, then run `docker compose up -d`. Compose reports the container as Running and changes nothing. Why?

  4. Q4. Which of these edits force Compose to stop, remove and recreate a container on the next `up`? Select all that apply.

  5. Q5. What does `internal: true` on a top-level network do?

Passing score: 75%. Answers are checked in this browser.