Docker & ContainersIX Β· Docker ComposeProduction patterns
Production Compose patterns β what works at scale
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
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.
upreplaces 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.
$ 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.041sIllustrative 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.
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/healthzStep 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.
| Command | Containers | Networks | Named volumes | Anonymous volumes |
|---|---|---|---|---|
docker compose stop | Stopped | Kept | Kept | Kept |
docker compose down | Removed | Removed | Kept | Kept |
docker compose down -v | Removed | Removed | Deleted | Deleted |
docker compose down --rmi all | Removed | Removed | Kept | Kept |
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.
- Set a top-level
name:so the project cannot collide with another checkout. - Pin every image by digest, and record the digests in the change ticket.
- Give every long-running service a healthcheck that fails when it should.
- Use
depends_onwithcondition: service_healthyfor stateful dependencies. - Set memory, cpus and pids limits on every service; sum them against host RAM.
- Set
restart: unless-stoppedon every service. - Bound the log driver, per service or in
daemon.json. - Put a reverse proxy in front for TLS, and do not gate it on backend health.
- Mount secrets from files synced by a secret manager, never from
environment:. - Deploy with
config --quiet, thenpull, thenup -d --wait; verify from outside. - Keep
-vout of every script that can run on a host you care about.
Knowledge check
Knowledge check Β· 5 questions
Q1. A production Compose stack should source secrets from:
Q2. Which Compose settings are essential for production hardening? Select all that apply.
Q3. A container without a memory limit leaks until the host is out of RAM. Which process is the kernel most likely to kill?
Q4. Adding `-v` to `docker compose down` is a safe way to force a fully clean redeploy on a production host.
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.