Skip to content

04 · Multi-Container App Patterns

Beyond "one container per service," a few recurring patterns compose containers to solve problems that don't fit neatly inside a single process: augmenting a main container without modifying it, translating between incompatible interfaces, and doing setup work before the main container starts.

Sidecar pattern

A sidecar runs alongside a main container, sharing its network namespace (and often a volume), adding a capability without changing the main container's image — log shipping, a proxy, a metrics exporter.

services:
  app:
    build: .
    volumes:
      - applogs:/var/log/app

  log-shipper:
    image: fluent/fluent-bit:latest
    volumes:
      - applogs:/var/log/app:ro
    depends_on:
      - app

volumes:
  applogs:

app writes logs to a shared volume; log-shipper reads them read-only and forwards them to an external aggregator — app's image never needed to know fluent-bit exists.

Ambassador pattern

An ambassador is a small proxy container that sits between your application and an external dependency, so the app always talks to a stable local address while the ambassador handles the real, possibly changing, endpoint.

services:
  app:
    build: .
    environment:
      - DB_HOST=db-ambassador
      - DB_PORT=5432

  db-ambassador:
    image: haproxy:2.9
    volumes:
      - ./haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro
# haproxy.cfg (excerpt)
frontend db_front
    bind *:5432
    default_backend db_actual
backend db_actual
    server db1 real-db-host-1:5432 check
    server db2 real-db-host-2:5432 check backup

If the real database's address changes (failover to a replica, migration to a new host), only haproxy.cfg changes — app's configuration (DB_HOST=db-ambassador) never does.

Init-container pattern

An init container runs to completion before the main container starts, performing setup the main container depends on — schema migrations, waiting for a dependency, fetching a config file.

services:
  migrate:
    build: .
    command: ["npm", "run", "migrate"]
    depends_on:
      db:
        condition: service_healthy
    restart: "no"

  api:
    build: .
    command: ["node", "src/server.js"]
    depends_on:
      migrate:
        condition: service_completed_successfully
      db:
        condition: service_healthy

  db:
    image: postgres:16
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      retries: 5

condition: service_completed_successfully (Compose's direct equivalent of Kubernetes's init-container concept) blocks api from starting until migrate has exited with code 0 — a failed migration (nonzero exit) correctly prevents api from ever starting against an unmigrated schema.

Worked example: combining sidecar and init-container

services:
  warm-cache:
    build: ./cache-warmer
    depends_on:
      cache:
        condition: service_started
    restart: "no"

  app:
    build: .
    depends_on:
      warm-cache:
        condition: service_completed_successfully
    volumes:
      - metrics:/var/run/metrics

  metrics-sidecar:
    image: prom/statsd-exporter
    volumes:
      - metrics:/var/run/metrics:ro

  cache:
    image: redis:7

volumes:
  metrics:

warm-cache pre-populates Redis before app starts serving traffic (init-container role); metrics-sidecar continuously exports whatever app writes to the shared metrics volume (sidecar role) — two different lifecycles, two different patterns, in one stack.

How It Actually Works

Why sidecars sharing "the same network namespace" is stronger than sharing a Compose network. In Kubernetes, sidecar containers within one Pod literally share a single network namespace — localhost inside one container reaches a port bound in the other, because there is only one network stack for the whole Pod. Plain Docker/Compose containers, in contrast, each get their own network namespace even on the same Compose network — they reach each other via their own IP addresses over the bridge, not via localhost. Docker does support the tighter, Kubernetes-Pod-like model directly: docker run --network container:other_container_name joins a new container to an existing container's network namespace rather than creating its own, which is the actual mechanism (--pid=container:X does the same for PID namespaces) — worth knowing when a sidecar genuinely needs localhost-level coupling rather than network-level coupling.

Why service_completed_successfully is a real distinct state, not just "container gone." Compose tracks a container's exit code the same way docker inspect -f '{{.State.ExitCode}}' would — a container that exits 0 (success) and one that exits 1 (a crashed migration) both leave the container in a Exited state indistinguishable by mere presence or absence, so Compose specifically checks the recorded exit code before satisfying that dependency condition. This is the same underlying signal covered in the lifecycle module (Level 1, module 07) and the OOM discussion (module 03 here) — Compose's dependency graph is, at bottom, just automated polling of the same State.ExitCode/State.Health fields you can inspect manually.

Exercise

Build an init-container pattern where a wait-for-db service uses pg_isready in a retry loop against db (rather than relying on db's own health check) and exits 0 only once it succeeds, and an app service that depends on wait-for-db completing successfully. Then break it deliberately — misconfigure db's credentials so wait-for-db never succeeds — and confirm app correctly never starts.