Skip to main content
RunBook Academy

Docker & ContainersIX Β· Docker ComposeProduction patterns

Production Compose patterns β€” what works at scale

Advanced⏱ ~28 mindocker

What you'll learn

  • Write a deploy sequence that fails loudly instead of half-succeeding
  • Measure and shrink the replacement window on a single-service update
  • Set resource limits, restart policies and log rotation that hold under load
  • Name the Compose commands that destroy data and keep them out of scripts

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.

Compose is a perfectly good production tool for a single host. The line is clear and worth stating plainly: one host, one Compose project. Past that boundary you need an orchestrator, and no amount of scripting around Compose will build you one.

Inside the boundary, what distinguishes a stack that survives from one that does not is almost never the YAML. It is the deploy procedure and the handful of commands that must never appear in it.

What works, and what does not

Compose does well:

  • A complete app stack on one VM β€” web, API, database, cache, log shipper, reverse proxy.
  • Sidecar patterns β€” a metrics exporter or log forwarder alongside a service.
  • Ephemeral environments β€” a whole stack per pull request, torn down on merge.
  • Self-hosted internal tools β€” dashboards, status pages, runners.

Compose does not do:

  • Multi-host scheduling. There is no scheduler.
  • Zero-downtime rolling deploys. up replaces containers; it does not shift traffic.
  • Autoscaling. No metric-driven scale controller exists.
  • Self-healing across host failure. Restart policies need a live daemon on a live host to act.

The replacement window nobody measures

This is the honest limitation and it is worth quantifying rather than hand-waving.

Read-only / Safemeasure it
$ time docker compose up -d --wait api
[+] Running 1/1
βœ” Container shop-api-1  Healthy                          13.8s

real    0m13.842s
user    0m0.089s
sys     0m0.041s

Illustrative output

Run that once against your real stack. Whatever it prints is your deploy outage, and it is usually longer than people guess.

The deploy sequence

The order matters, and each step exists because of a specific failure.

Service impact possibledeploy
set -euo pipefail
cd /srv/shop

# 1. Validate the file before anything else. Catches a bad merge or a
#    missing variable without contacting the daemon.
docker compose config --quiet

# 2. Pull first, separately. A registry outage fails here, while the old
#    stack is still serving, instead of halfway through a replacement.
docker compose pull

# 3. See what is about to be replaced. Log it; this is your change record.
docker compose --dry-run up -d

# 4. Apply, and block on readiness. Non-zero exit means a failed deploy.
docker compose up -d --wait --wait-timeout 180

# 5. Verify from outside the stack, not from inside it.
curl -fsS https://shop.example.com/healthz

Step 2 is the one people skip and the one that pays. docker compose up -d on its own pulls lazily, service by service, mid-replacement β€” so a registry that goes away at the wrong moment leaves you with half the stack on the new version and half stopped.

Step 5 matters because every check inside the stack shares the stack’s assumptions. A container that reports healthy to its own healthcheck can still be unreachable through the proxy, because the proxy resolved the old container’s IP and has not re-resolved it.

CommandContainersNetworksNamed volumesAnonymous volumes
docker compose stopStoppedKeptKeptKept
docker compose downRemovedRemovedKeptKept
docker compose down -vRemovedRemovedDeletedDeleted
docker compose down --rmi allRemovedRemovedKeptKept

The patterns

Reverse proxy in front

services:
  caddy:
    image: caddy:2.9
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    networks: [edge, app]

  api:
    image: myorg/api:1.4.0
    restart: unless-stopped
    networks: [app]
    healthcheck:
      test: ["CMD", "/app/server", "healthcheck"]
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 30s

caddy_data is not optional decoration. It holds the ACME account key and the issued certificates; losing it means re-issuing every certificate, and Let’s Encrypt rate limits are per-week. This is the volume that down -v takes that nobody thinks about until the reissue fails.

Note also that the proxy does not use depends_on: service_healthy here. A proxy that refuses to start because one backend is unhealthy takes down every other site on the host. Let it start, let it return 502 for the backend that is down, and let it recover on its own when the backend comes back.

Restart policy on everything

restart: unless-stopped

no is the default, and a container with the default does not come back after a host reboot. The two candidates in production are always and unless-stopped; the difference appears exactly once, on the reboot after you deliberately stopped a container. always restarts it anyway; unless-stopped respects your decision. Prefer unless-stopped β€” the manual stop was almost certainly intentional.

A restart policy needs a running daemon to act on it. If dockerd itself dies, nothing restarts anything, which is what live-restore in daemon.json addresses for the containers already running.

Resource limits on everything

services:
  api:
    deploy:
      resources:
        limits:
          cpus: "2.0"
          memory: 1g
          pids: 500
        reservations:
          memory: 256m
  db:
    deploy:
      resources:
        limits:
          cpus: "4.0"
          memory: 4g
          pids: 200

Compose applies these; deploy.resources is not Swarm-only. The shorter non-deploy spellings mem_limit, cpus and pids_limit are equivalent β€” pick one style per file rather than mixing them.

pids is the limit people leave out and it is the cheapest one. A fork bomb, or more realistically a runaway worker pool, exhausts the host PID space and you cannot even SSH in to fix it. A few hundred is generous for most services.

Log rotation, before it is a problem

services:
  api:
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

The json-file driver’s default is unbounded. A chatty service writes to /var/lib/docker/containers/<id>/<id>-json.log until the filesystem is full, at which point every container on the host starts failing writes at once and the cause is one service nobody was watching.

Set it per service, or better, set it once in daemon.json so a service that forgets still inherits a bound. Note that changing the default only affects containers created afterwards.

  1. Set a top-level name: so the project cannot collide with another checkout.
  2. Pin every image by digest, and record the digests in the change ticket.
  3. Give every long-running service a healthcheck that fails when it should.
  4. Use depends_on with condition: service_healthy for stateful dependencies.
  5. Set memory, cpus and pids limits on every service; sum them against host RAM.
  6. Set restart: unless-stopped on every service.
  7. Bound the log driver, per service or in daemon.json.
  8. Put a reverse proxy in front for TLS, and do not gate it on backend health.
  9. Mount secrets from files synced by a secret manager, never from environment:.
  10. Deploy with config --quiet, then pull, then up -d --wait; verify from outside.
  11. Keep -v out of every script that can run on a host you care about.

Knowledge check

Knowledge check Β· 5 questions

  1. Q1. A production Compose stack should source secrets from:

  2. Q2. Which Compose settings are essential for production hardening? Select all that apply.

  3. Q3. A container without a memory limit leaks until the host is out of RAM. Which process is the kernel most likely to kill?

  4. Q4. Adding `-v` to `docker compose down` is a safe way to force a fully clean redeploy on a production host.

  5. Q5. Why should `docker compose pull` be a separate step before `docker compose up -d` in a deploy script?

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