Why a checklist before production
Docker Compose was designed for development: its defaults favor simplicity over robustness. A container will not restart after a host reboot, its logs grow without limit, it can consume all the machine's RAM, and it listens on every network interface. In development, none of these behaviors cause problems because you relaunch the stack by hand several times a day. In production, these missing settings turn into incidents: full disk at 3 a.m., database lost after a docker compose down, service unreachable after a power outage. The good news: hardening a Compose stack requires no rewrite, only a dozen targeted additions.
The 10 points at a glance
- restart: unless-stopped — the container restarts after a host reboot or crash
- healthcheck — Docker detects a stuck container and enables rolling restarts
- CPU/memory limits — a runaway service can no longer starve its neighbors
- secrets via .env or Docker secrets — never a password in cleartext in the Compose file
- named volumes — data survives a
docker compose down - log rotation —
max-sizeandmax-filestop the disk from filling up - network isolation — separate
frontendandbackend, do not expose everything on the default bridge - port binding —
127.0.0.1:PORTbehind a reverse proxy, not0.0.0.0 - pinned image tags — a version or digest, never
latest - depends_on with condition —
service_healthyavoids startup races
Prerequisites
Before applying this checklist, ensure you have a VPS with Docker Engine and the Compose v2 plugin installed (the command is docker compose, no hyphen, since 2022). Check the version with docker compose version: the deploy.resources syntax outside Swarm requires Compose v2. Place your file in a dedicated project folder, for example /opt/myapp, with a .env file beside it and restricted permissions (chmod 600 .env). Plan for a reverse proxy upstream — Traefik, Caddy, or Nginx — because several settings, notably port binding, assume that public traffic never hits your containers directly. Keep a copy of your Compose file under version control.
The 10 detailed steps
1. Restart policy
Add restart: unless-stopped to each service. The container restarts after a crash or host reboot, but stays stopped if you stopped it intentionally. Avoid restart: always, which would relaunch even a container you intended to keep off.
2. Healthcheck
Declare a healthcheck: block with a test (for example curl -f http://localhost:8080/health || exit 1), an interval, a timeout, and retries. Docker then marks the container healthy or unhealthy, allowing other services to react to a stall.
3. Resource limits
Under deploy.resources.limits, set cpus and memory (for example memory: 512M). Without a limit, a leaking service can consume all the RAM and get the others killed by the OOM killer. Also add reservations to guarantee a minimum.
4. Secrets out of the file
Never put a password in cleartext in the YAML. Reference them via env_file: .env or ${VARIABLE}, or use Docker's secrets: mechanism that mounts the secret as a file in the container. Add .env to your .gitignore.
5. Named volumes
Declare your data in named volumes with an explicit driver rather than an anonymous bind-mount. A named volume survives docker compose down; only down -v deletes it. Document each volume to know what you are backing up.
6. Log rotation
Add a logging: block with driver: json-file and options max-size: "10m" and max-file: "3". Without it, a chatty container's logs fill the disk until failure. Apply it to every service.
7. Network isolation
Create named networks — a frontend for what is exposed, a backend for the database — and attach each service only to the networks it needs. Your database should only be reachable by the application, never from the shared default bridge.
8. Port binding
Behind a reverse proxy, publish on 127.0.0.1:8080:8080, not 8080:8080 (which equals 0.0.0.0). Otherwise the port remains reachable from the internet despite the proxy, bypassing your TLS and authentication rules.
9. Pinned image tags
Replace image: postgres:latest with a specific version (postgres:16.3) or, better, a digest (postgres@sha256:...). latest changes without warning and makes your deployments non-reproducible. Combine with pull_policy: missing for predictable behavior.
10. Startup order
Use depends_on with condition: service_healthy so a service waits not just for launch but for the real availability of its dependency. This requires a healthcheck on the dependent service (step 2) and eliminates startup races.
Dev defaults vs production settings
| Setting | Dev default | Recommended for prod |
|---|---|---|
| restart | no | unless-stopped |
| healthcheck | absent | defined with interval and retries |
| memory | unrestricted | limit set (e.g. 512M) |
| secrets | cleartext possible | .env or Docker secrets |
| volumes | anonymous | named with driver |
| logs | unbounded | max-size + max-file |
| network | default bridge | separate frontend / backend |
| ports | 0.0.0.0 | 127.0.0.1 behind proxy |
| image | latest | pinned version or digest |
| depends_on | launch only | condition: service_healthy |
Always test your hardened stack locally before pushing to production: run docker compose config to validate the syntax, then docker compose up and simulate a reboot with docker compose restart. Verify that containers come back healthy and that data persists after a down followed by an up.
Troubleshooting
If a container stays stuck in starting, your healthcheck is failing: test the test command manually with docker compose exec service sh and verify it returns 0. A service restarting in a loop (Restarting) usually hides a startup error — check docker compose logs -f service. If deploy.resources.limits seems ignored, remember that in Compose v2 outside Swarm, limits are applied but reservations only take effect in Swarm mode. A port still reachable despite 127.0.0.1 usually signals a missing firewall or a Docker rule bypassing UFW — check with ss -tlnp. If docker compose down deleted your data, it is almost always because -v was appended or the volume was anonymous rather than named.
CVE-2026-17106 (CopyEscape): update Docker Engine now
CVE-2026-17106, nicknamed CopyEscape, is a race condition in docker cp disclosed on 10 August 2026. An untrusted container can produce a malformed tar archive that follows a symlink outside the destination, resulting in arbitrary file overwrite on the host — including the runc binary. Surface: any VPS running docker cp from a container whose content you do not control. The fix is in Docker Engine ≥ 29.7.2 and Docker Desktop ≥ 4.86.0. Check your version with docker version and upgrade before exposing a new service. If an immediate upgrade is not possible, avoid docker cp from untrusted containers and enforce least privilege (--cap-drop ALL).
Docker secrets management: rotation without downtime
Cleartext environment variables in a Compose file are the most common production secret leak: they appear in docker inspect, error logs, and process dumps. Docker's secrets: mechanism — or an external manager such as Vault or Infisical — mounts secrets as files under /run/secrets/, out of reach of inspect. For rotation without downtime, version the secret (create db_password_v2 in parallel with v1), update the service to read v2, redeploy with a rolling update (docker compose up -d --no-deps service), then remove v1 once the deployment is validated. No service interruption, no window where the secret is exposed between versions.
Backing up volumes without corruption
Copying files from a PostgreSQL or MySQL volume while running with rsync or tar almost always produces a corrupt backup: the engine writes continuously and data pages are captured at different checkpoints. The rule is to always run a SQL dump before snapshotting the volume: pg_dump or mysqldump produce a consistent state you can archive or transfer. To automate this on a Docker VPS, tools like Restic and Offen Docker Backup orchestrate database quiesce, SQL dump, encrypted snapshot, and upload to remote storage. Document each named volume (step 5 of the checklist) and pair it with a tested restore procedure: a backup without a restore test is unprotected data.
Conclusion
These ten settings turn a development Compose file into a production stack able to survive reboots, load spikes, and unsupervised nights. None requires an extra tool: everything fits in the YAML you already have. Get into the habit of running this checklist before every go-live, ideally as a docker compose config review built into your deployment. Once these foundations are in place, you can add a reverse proxy like Traefik or Caddy, or an orchestration layer like Coolify, with full confidence.