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.
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
services:db:image: postgres:17-alpinerestart: unless-stoppedenvironment:POSTGRES_PASSWORD_FILE: /run/secrets/db_passwordsecrets: [db_password]volumes: [pgdata:/var/lib/postgresql/data]healthcheck:test: ["CMD-SHELL", "pg_isready -U app -d app"]interval: 10stimeout: 5sretries: 5start_period: 20slogging:options: { max-size: "20m", max-file: "5" }api:image: ghcr.io/acme/api:1.4.2restart: unless-stoppeddepends_on:db: { condition: service_healthy }healthcheck:test: ["CMD", "/app", "healthcheck"]interval: 15sretries: 3deploy:resources:limits: { cpus: "1.0", memory: 512M }read_only: truetmpfs: [/tmp]secrets: [db_password, api_token]logging:options: { max-size: "20m", max-file: "5" }secrets:db_password:file: ./secrets/db_password.txtapi_token:file: ./secrets/api_token.txtvolumes: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.
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 Healthydocker 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 startdocker 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=falsebind secrets/api_token.txt -> /run/secrets/api_token rw=falsedocker compose exec api stat -c "uid=%u gid=%g mode=%a %n" /run/secrets/api_token /run/secrets/db_passworduid=0 gid=0 mode=644 /run/secrets/api_token # asked for uid 1000, gid 1000, mode 0400 in the fileuid=0 gid=0 mode=644 /run/secrets/db_passwordP2C_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_tokenuid=0 gid=0 mode=444 /run/secrets/token_env # environment-sourced, no attributes: Compose's 0444uid=1000 gid=1000 mode=400 /run/secrets/token_env_0400 # environment-sourced with uid/gid/mode: honouredthe 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 modeOperating 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).
git diff HEAD~1 -- compose.yaml | grep image:- image: ghcr.io/acme/api:1.4.2+ image: ghcr.io/acme/api:1.5.0docker compose config --quiet && docker compose pull api && docker compose up -d apidocker 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 directionThe 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.
docker compose exec api touch /tmp/crash # the fixture api exits 3 when this file appearswait_restarted p2c-compose-api-1 20 # fixture helper: polls .State.Status and .RestartCount twice a secondrunning again after 1s, RestartCount=1docker kill p2c-compose-api-1 && sleep 3 && docker inspect -f "status={{.State.Status}} exit={{.State.ExitCode}}" p2c-compose-api-1status=exited exit=137docker compose up -d api && docker compose stop api && sleep 3 && docker inspect -f "status={{.State.Status}} restarting={{.State.Restarting}}" p2c-compose-api-1status=exited restarting=falsedocker compose up -d api && docker compose ps --format "table {{.Name}} {{.Status}}"NAME STATUSp2c-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 upWhen an upgrade or a secret change goes wrong: recovery in order of preference
| Situation | Do | Do not |
|---|---|---|
the new image tag is unhealthy after up -d | git 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 directions | edit 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 authenticate | restore 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 localhost | fix 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 host | give 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 service | run the service as root to make the read work |
docker compose config fails after an edit | nothing restarted; fix the interpolation or YAML and run config again until it renders, then up -d | export the missing variable in your shell to get past it: the next reboot will not have it |