Docker Compose in production: the parts that bite

Restart policies, health-gated dependencies, resource caps, and secrets — what to fix before Compose meets real traffic.

Jul 2, 2024·Updated ·9 min readIntermediate·By SecOpsLog · command-tested

A compose.yaml that works on a laptop has usually been shaped by three habits that do not survive a production host: depends_on: [db] (which waits for the database container to start, not for the database to accept connections), credentials in .env (which docker inspect prints to anyone with socket access), and :latest tags (which make every restart a surprise upgrade). Compose can run a small production stack on one VM for years; the file just has to be written for reboots, crashes and the person who will inspect it, and not for the demo.

The file, production-shaped

compose.yaml
services:
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets: [db_password]
volumes: [pgdata:/var/lib/postgresql/data]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
logging:
options: { max-size: "20m", max-file: "5" }
api:
image: ghcr.io/acme/api:1.4.2
restart: unless-stopped
depends_on:
db: { condition: service_healthy }
healthcheck:
test: ["CMD", "/app", "healthcheck"]
interval: 15s
retries: 3
deploy:
resources:
limits: { cpus: "1.0", memory: 512M }
read_only: true
tmpfs: [/tmp]
secrets: [db_password, api_token]
logging:
options: { max-size: "20m", max-file: "5" }
secrets:
db_password:
file: ./secrets/db_password.txt
api_token:
file: ./secrets/api_token.txt
volumes:
pgdata:

Each line above answers a specific failure. condition: service_healthy makes Compose wait for the database healthcheck instead of the container start, so the API does not race the database on every reboot; in the run the api container's StartedAt was half a second after the db's first passing probe. start_period gives Postgres time to initialise on first boot before failed probes count. restart: unless-stopped brings services back after a crash or a host reboot but respects a deliberate docker compose stop during maintenance, which always does not. A crash means the process exiting on its own: when the fixture's api exited 3, Compose's policy had it running again within a second with RestartCount 1; after docker kill it stayed exited with code 137, because a kill from the socket counts as a stop. deploy.resources.limits applies cgroup limits under plain Compose too (the api container carried Memory=536870912 and NanoCpus=1000000000 in docker inspect), so a memory leak in the API is an OOM kill of one container rather than of the host. read_only with a tmpfs for /tmp and log rotation on every service are the two settings people add after the incident instead of before; touch /marker in the api container answered Read-only file system and /tmp/marker worked.

Secrets as files, and what Compose will not do for them

A value in environment: appears in docker inspect, in /proc/<pid>/environ, in crash dumps and in any child process that inherits the environment. Compose secrets mount the value as a file under /run/secrets/<name> instead, and most images that matter accept a _FILE variant of their credential variable (POSTGRES_PASSWORD_FILE above). How far to trust this depends on where the secret comes from, and three things need keeping apart. What the Compose file reference says: the secret is mounted read-only, the long-syntax mode defaults to 0444, and uid, gid and mode are implemented only when the secret's source is environment; with a file source Compose uses a bind mount and the three attributes are, in the documentation's words, silently ignored. What Docker Compose v2.40.3 did in the run: a file: secret was a read-only bind mount of the host file with no mode of Compose's own, and the three attributes on it were ignored with a warning printed at up rather than silently; an environment: secret was a file Compose wrote into the container, 0444 root-owned by default, 0400 and 1000:1000 when asked. What the platform added: the run happened on a macOS host whose Linux VM presents shared files as root-owned, so the file secrets showed up as uid=0 gid=0 mode=644; on a native Linux filesystem (checked once in a Docker-in-Docker daemon, outside the fixture) the container sees the host file's exact owner and mode, so a service running as 1000:1000 read a 600 file it owned and could not read a root-owned 640 one. The conclusion is the same on both: for file-backed secrets the host file is the permission model. Protect it there (chmod 600 and the right owner, on an encrypted disk), give a service that runs unprivileged a uid or group that can read it, and treat this as a single-host arrangement: a multi-host deployment wants an external secret store.

bash — observed: file secrets and environment secrets side by side (Compose v2.40.3)observed
docker compose up -d --wait 2>&1 | grep -E "not supported|Healthy"
warning msg="secrets `uid`, `gid` and `mode` are not supported, they will be ignored"
Container p2c-compose-db-1 Healthy
Container p2c-compose-api-1 Healthy
docker inspect p2c-compose-api-1 --format "{{.Config.Env}}"
[APP_ENV=production PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin]
no credential in the environment; the process reads /run/secrets/api_token at start
docker inspect p2c-compose-api-1 -f "{{range .Mounts}}{{.Type}} {{.Source}} -> {{.Destination}} rw={{.RW}}{{println}}{{end}}"
bind secrets/db_password.txt -> /run/secrets/db_password rw=false
bind secrets/api_token.txt -> /run/secrets/api_token rw=false
docker compose exec api stat -c "uid=%u gid=%g mode=%a %n" /run/secrets/api_token /run/secrets/db_password
uid=0 gid=0 mode=644 /run/secrets/api_token # asked for uid 1000, gid 1000, mode 0400 in the file
uid=0 gid=0 mode=644 /run/secrets/db_password
P2C_TOKEN=… docker compose -f compose.envsecret.yaml run --rm probe # the same three attributes on a secret with environment:
uid=0 gid=0 mode=644 /run/secrets/api_token
uid=0 gid=0 mode=444 /run/secrets/token_env # environment-sourced, no attributes: Compose's 0444
uid=1000 gid=1000 mode=400 /run/secrets/token_env_0400 # environment-sourced with uid/gid/mode: honoured
the file secrets are the host files, mounted read-only; the 644 and uid 0 are what this macOS host's VM presents for a 644 file, not a Compose default. The environment secrets are files Compose wrote, and only those take uid, gid and mode
Go deeper in a courseDocker in depthCompose, networking, storage, and running containers reliably on a single host.View course

Operating it: upgrade, verify, roll back

Pinned tags mean upgrades happen when you change the file, which is the point: the change is a commit, the rollout is pull then up -d, and the rollback is the previous commit. docker compose config renders the merged file and catches YAML and interpolation mistakes before anything restarts (a ${API_URL:?API_URL must be set} left unset came back as required variable API_URL is missing a value, exit 1); docker compose ps shows health, not just running state, and is the check to run before declaring the upgrade done. One check the runbook should not skip on first deploy: that the _FILE variable was read at all. The postgres image trusts connections from inside its own container, so a psql there proves nothing about the password; the fixture logged in from another container on the Compose network with the file's value (scram, accepted) and with a wrong one (password authentication failed for user "app", exit 2).

bash — representative: the upgrade runbook (a registry and a real release; not part of the fixture)
git diff HEAD~1 -- compose.yaml | grep image:
- image: ghcr.io/acme/api:1.4.2
+ image: ghcr.io/acme/api:1.5.0
docker compose config --quiet && docker compose pull api && docker compose up -d api
docker compose ps --format "table {{.Name}}\t{{.Status}}"
api Up 40 seconds (healthy)
db Up 3 days (healthy)
rollback: git revert, then the same pull + up -d; the db volume is untouched by either direction

The rollback in that last line only holds if the new version did not migrate the database schema forward. A release that runs migrations needs its own backward-compatibility plan (expand-then-contract, or a backup taken before up -d), because git revert restores the old binary against a schema it may not understand. That is a property of the application, not of Compose, but Compose gives you nothing to hide behind: there is no rolling update and no second replica to keep serving while the first one fails.

bash — observed: what the restart policy does with a crash, a kill and a stopobserved
docker compose exec api touch /tmp/crash # the fixture api exits 3 when this file appears
wait_restarted p2c-compose-api-1 20 # fixture helper: polls .State.Status and .RestartCount twice a second
running again after 1s, RestartCount=1
docker kill p2c-compose-api-1 && sleep 3 && docker inspect -f "status={{.State.Status}} exit={{.State.ExitCode}}" p2c-compose-api-1
status=exited exit=137
docker compose up -d api && docker compose stop api && sleep 3 && docker inspect -f "status={{.State.Status}} restarting={{.State.Restarting}}" p2c-compose-api-1
status=exited restarting=false
docker compose up -d api && docker compose ps --format "table {{.Name}} {{.Status}}"
NAME STATUS
p2c-compose-api-1 Up 2 seconds (healthy)
p2c-compose-db-1 Up 30 seconds (healthy)
unless-stopped restarts a process that died; a kill or a stop through the socket is a decision, and the container waits for the next up

When an upgrade or a secret change goes wrong: recovery in order of preference

SituationDoDo not
the new image tag is unhealthy after up -dgit revert the image line, docker compose up -d <service>; docker compose ps must show (healthy) before the incident is over; the db volume is untouched in both directionsedit the tag by hand on the host: the file in Git no longer describes what runs
a secret file was replaced with a wrong value and the service cannot authenticaterestore the previous file from where secrets are kept (not from Git) and docker compose up -d --force-recreate <service>: the bind mount is read at container start; verify with the service's own login, from another container if the image trusts localhostfix it with docker compose exec inside the running container: the mount is read-only and the change is lost on recreate
a service runs as an unprivileged uid and cannot read /run/secrets/<name> after a chmod 600 on the hostgive the host file a group the service uid belongs to (chgrp, mode 640), or move the value to an environment-sourced secret with uid/gid/mode, which Compose honours; recreate the servicerun the service as root to make the read work
docker compose config fails after an editnothing restarted; fix the interpolation or YAML and run config again until it renders, then up -dexport the missing variable in your shell to get past it: the next reboot will not have it
What was run for this article
Docker Compose v2.40.3 on Docker Engine 28.5.2 (linux/arm64, macOS host) with the file above, except that the api service is an alpine:3.22 container that reads its secrets and waits, and postgres:17-alpine by digest. The terminal blocks marked observed are copied from that run; nineteen exit codes are asserted: config validation and a broken interpolation, up --wait, the service_healthy ordering by timestamps, the environment, the secret mounts and modes for both secret sources, a scram login over the network with the _FILE password and with a wrong one, read_only and tmpfs, the cgroup limits, the restart policy against a crash, a kill and a stop, and log rotation options. The secret file modes are those of a macOS host with a Linux VM (OrbStack), where bind-mounted files appear root-owned and a host chmod is remapped by the file-sharing layer; the native-Linux statement (the container sees the host file's exact uid, gid and mode) was checked once in a Docker-in-Docker daemon with Compose v2.40.3 on an overlay filesystem, which is not part of the fixture. The upgrade runbook, the real api image and the migration discussion are representative; the recovery table follows the documented model except the config and restart rows, which the run exercised.
Know the edge of Compose
One host means one failure domain: no replicas, no rolling updates, no pod disruption budgets, no network policy between services beyond what you write into the Docker network. Compose is a good fit for an edge appliance or a small internal stack with a maintenance window. When the requirement becomes zero-downtime upgrades or more than one host, the same health and secret discipline moves to Kubernetes; the file above is most of the translation.

Related posts

Quick reference