Docker Compose for Production: The Patterns That Separate a Working Stack from a Reliable One

2026-11-05 · Unix3r

Healthchecks that gate startup, networks that isolate databases, resource limits that stop one container from taking down the box, read-only filesystems, log rotation, secrets, and the compose features most people never turn on — with a reference stack you can copy

Most Compose files on the internet are the minimum that boots. They work on day one and then, months later, a database starts before its app is ready, a runaway container eats all the RAM, and docker logs has quietly filled the disk.

This post is the set of patterns I apply to every stack now — each one a few lines, each one fixing a specific failure I've had. There's a complete reference docker-compose.yml at the end that uses all of them.

1. Healthchecks, and dependson that actually waits

dependson alone only orders starting, not readiness. Postgres "starts" a second before it accepts connections, and your app crashes on first boot. Fix it with a healthcheck and condition: servicehealthy:

Note the $$ — a single $ would be interpolated by Compose from your shell, $$ passes a literal $ to the container.

Healthchecks for the usual suspects:

startperiod is the grace window where failures don't count — set it to however long the service takes to boot, or the first-run DB migration will mark it unhealthy and restart loops begin.

2. Networks: the database should not be reachable from other stacks

By default every service in a Compose file shares one network, and if you use networkmode: host or a shared external network, everything can reach everything. Put the database on a network only its app can see:

internal: true is underused: the database has no reason to talk to the internet, so don't let it. If it's ever compromised, it can't phone home.

3. Publish ports to an address, never 0.0.0.0

Better still: if a reverse proxy on the same Docker network fronts the service, publish nothing. The proxy reaches it by container name on the shared network; expose: is documentation-only and doesn't open anything.

4. Resource limits

One container with a memory leak can OOM the entire host and take every other service with it. Compose v2 supports limits without Swarm:

Set the memory limit a bit above the service's real peak (check with docker stats after a week). When it's hit, only that container is killed and restarted — not the host.

For databases, give the limit and tell the database about it, or it'll plan for RAM it doesn't have:

5. Restart policy

Not always — always restarts a container you deliberately stopped with docker stop the next time the daemon restarts. unless-stopped respects your intent. Not on-failure either, unless it's a one-shot job.

6. Log rotation

docker logs is backed by a JSON file that grows forever by default. Set the limit globally in the daemon so you never have to think about it per-stack:

Or per service when one is especially chatty:

Check how bad it currently is:

7. Read-only root filesystem and dropped capabilities

Most application containers never need to write anywhere except a volume and /tmp. Make the rest read-only; a compromised process can't modify the binary it's running.

Turn these on one at a time and watch the logs — the app will tell you which path it needed. Databases generally can't run readonly (they write to many paths); for those, no-new-privileges and capdrop: ALL still apply.

Run as non-root when the image supports it:

LinuxServer.io images use PUID/PGID environment variables instead; official images vary. Check the image docs — forcing user: on an image that expects root will fail on first write.

8. Volumes: bind mounts vs named volumes

Bind mounts win for a homelab: backups are restic backup /opt/stacks, you can inspect files directly, and moving a stack to another box is rsync + docker compose up. Named volumes are fine for throwaway caches.

Two bind-mount rules:

Pre-create the directory with the right owner. If Docker creates it, it's root:root and a non-root container can't write.
Never bind-mount the Docker socket into anything that doesn't absolutely need it (Traefik, Watchtower, a CI runner). Socket access is root on the host.

9. Secrets without putting them in environment:

Environment variables show up in docker inspect, in /proc, and in crash dumps. Compose supports file-based secrets outside Swarm:

Most official database images and many apps support the FILE suffix convention. For ones that don't, the .env file + ${VAR} interpolation is the fallback — still better than hardcoding, still keep it chmod 600 and out of git.

10. Pin image versions

latest means "whatever happens to be there when I pull," which is how a docker compose pull jumps two major versions and fails the migration. Pin exact tags and update them deliberately (Renovate automates the PRs).

11. init: true for proper signal handling

Containers whose main process isn't designed to be PID 1 don't reap zombies and ignore SIGTERM, so docker stop waits 10 seconds and then SIGKILLs them — mid-write, if you're unlucky.

This injects tini as PID 1. Costs nothing, and docker compose down becomes fast and clean.

For services that take a long time to shut down gracefully (databases flushing, queues draining):

12. Labels and project naming

Labels let scripts find things: docker ps --filter label=com.example.backup=true gives the backup script its list without hardcoding names.

13. Profiles for optional pieces

One file, one stack, with the optional components documented in place instead of in a separate "if you also want X" paragraph.

14. Validate before you apply

Put config -q in CI so a broken file never reaches the server.

15. Override files for per-host differences

The same stack on a VPS and on a home box differ in bind address and maybe a resource limit. Don't fork the file:

docker compose up merges docker-compose.yml + docker-compose.override.yml automatically. The base file stays identical in git on every host.

Reference stack

Everything above, in one file. It's a generic web app with Postgres and Redis behind a shared reverse-proxy network; swap the app image for yours.

Every container shows (healthy) within a minute, the database is unreachable from anything but app, no port is open to the world, logs are capped at 30 MB per container, and a leak in the app can't take more than 1 GB.

The short version

If you take five things: healthchecks with servicehealthy, an internal backend network, ports bound to an address, memory limits, and daemon-level log rotation. Those five turn a stack that works into a stack that keeps working while you're not looking.