Tutorial

Deployment

Docker Compose in Production: 10-Point Checklist

A docker-compose.yml that runs on your laptop won't survive production as-is. Restart after reboot, resource limits, secrets, log rotation: these settings are missing by default and cause silent outages once the service is exposed. This checklist gathers the 10 parameters to verify before deploying a Compose stack on a VPS, then health checks, secrets, rollback and troubleshooting. It also covers the move from `docker-compose` to `docker compose`, which upgrading to Ubuntu 26.04 forces you to settle.

12 min read10 steps
Contents· Why a checklist before production1/13
  1. 01Why a checklist before production
  2. 02The 10 points at a glance
  3. 03Prerequisites
  4. 04The 10 steps in detail
  5. 05Dev defaults vs production settings
  6. 06docker-compose or docker compose: the Ubuntu 26.04 break
  7. 07Migrate your scripts and add a shim
  8. 08Advanced health checks: monitoring real service state
  9. 09Docker secrets without Swarm: mounting files in /run/secrets/
  10. 10Rollback: recovering from a failed deployment
  11. 11Troubleshooting: 6 common errors and their solutions
  12. 12Backing up volumes without corruption
  13. 13Conclusion

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. Run through each point before going live, and your service will handle load and reboots without constant monitoring.

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 plaintext password in the Compose file
  • named volumes — data survives a docker compose down
  • log rotation — max-size and max-file prevent disk saturation
  • network isolation — separate frontend and backend, don't expose everything on the default bridge
  • port binding — 127.0.0.1:PORT behind a reverse proxy, not 0.0.0.0
  • pinned image tags — a version or digest, never latest
  • depends_on with condition — service_healthy prevents startup races

Prerequisites

You need a VPS with Docker Engine and the Compose v2 plugin. The command is docker compose, without a hyphen: check it with docker compose version, because the deploy.resources syntax outside Swarm requires Compose v2 (the old docker-compose is covered below). Check the engine too with docker version. Docker Engine 29.7.2 fixes CVE-2026-17106 (CopyEscape), a race condition in docker cp disclosed on 10 August 2026: an untrusted container can use it to overwrite a file on the host, including the runc binary. On workstations, the fix shipped in Docker Desktop 4.86.0. Until you update, do not run docker cp from a container whose content you do not control. Keep the project in a dedicated folder (/opt/myapp), with a .env set to chmod 600. Plan for an upstream reverse proxy — Traefik, Caddy or Nginx — because port binding assumes public traffic never reaches your containers, and keep the Compose file under version control.

The 10 steps in detail

  1. 1. Restart policy

    Add restart: unless-stopped to every service. The container restarts after a crash or host reboot, but stays stopped if you intentionally stopped it. Avoid restart: always which would relaunch even a container you wanted to leave stopped, and prefer unless-stopped for application services and on-failure for one-off tasks that should not loop indefinitely.

  2. 2. Healthcheck

    Declare a healthcheck: block with a test (e.g. curl -f http://localhost:8080/health || exit 1), an interval (check frequency, e.g. 30s), a timeout (failure threshold, e.g. 10s) and retries (consecutive failures before switching to unhealthy, e.g. 3). Also add start_period: 40s for services that take time to boot, to avoid marking them unhealthy during initialization. Docker then marks the container healthy or unhealthy, allowing other services to react to a stall via depends_on: condition: service_healthy.

  3. 3. Resource limits

    Under deploy.resources.limits, set cpus and memory (e.g. memory: 512M). Without a limit, a leaking service can consume all RAM and cause the OOM killer to terminate others. Also add reservations to guarantee a minimum at startup. In Compose v2 outside Swarm, limits are applied as cgroup constraints; reservations are declarative and do not block startup, but serve as a planning reference.

  4. 4. Secrets outside the file

    Never put a plaintext password in the YAML. Reference them via env_file: .env or ${VARIABLE}, or use Docker's secrets: mechanism which mounts the secret as a file in /run/secrets/ inside the container, out of reach of docker inspect. Add .env to your .gitignore and check with git status that this file is never tracked.

  5. 5. Named volumes

    Declare your data in named volumes with an explicit driver rather than anonymous bind-mounts. A named volume survives docker compose down; only down -v removes it. Document each volume to know what you are backing up, and associate it with a tested backup strategy: a backup without a restore test is unverified data.

  6. 6. Log rotation

    Add a logging: block with driver: json-file and options max-size: "10m" and max-file: "3". Without this, a chatty container's logs fill the disk until failure. Apply it to every service. For high-volume stacks, consider the local driver (automatic compression) or an external driver like loki if you have a centralized logging infrastructure.

  7. 7. Network isolation

    Create named networks — a frontend for exposed services, 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. Add internal: true to the backend network to prevent any access from the host or the internet, even in the event of a port configuration error.

  8. 8. Port binding

    Behind a reverse proxy, publish on 127.0.0.1:8080:8080 rather than 8080:8080 (which is equivalent to 0.0.0.0). Otherwise the port remains accessible from the internet despite the proxy, bypassing your TLS and authentication rules. Note: Docker directly modifies iptables rules and can bypass UFW — binding to 127.0.0.1 is the only reliable guarantee on the Compose side.

  9. 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: Compose only pulls a new image if it is absent locally, avoiding unexpected updates on docker compose up.

  10. 10. Start order

    Use depends_on with condition: service_healthy so a service waits not just for its dependency to start but to be genuinely available. This requires a healthcheck on the dependency (step 2) and eliminates startup races. For services that do not support a native healthcheck, you can use condition: service_started combined with a wait script (wait-for-it.sh or dockerize).

Dev defaults vs production settings

Scroll the table

ParameterDev defaultRecommended in prod
restartnounless-stopped
healthcheckabsentdefined with interval and retries
memoryunconstrainedlimit set (e.g. 512M)
secretsplaintext possible.env or Docker secrets
volumesanonymousnamed with driver
logsno limitmax-size + max-file
networkdefault bridgefrontend / backend separated
ports0.0.0.0127.0.0.1 behind proxy
imagelatestversion or pinned digest
depends_onstart onlycondition: service_healthy

docker-compose or docker compose: the Ubuntu 26.04 break

Two tools carry almost the same name. docker-compose, with a hyphen, is Compose v1, written in Python: its repository declares it end of life since the general availability of Compose v2, announced by Docker on 26 April 2022. docker compose, with a space, is Compose v2, written in Go and plugged into the Docker CLI as a plugin. Ubuntu 26.04 completes the transition: the docker-compose package has been out of the archive since Ubuntu 25.10, and the docker-compose-v2 package installs only the plugin, in /usr/libexec/docker/cli-plugins/. After the upgrade, any script that still calls the old name stops on docker-compose: command not found. On the CI side, GitHub took Compose v1 out of its hosted runners on 9 July 2024, and moves ubuntu-latest to Ubuntu 26.04 between 19 October and 19 November 2026: a workflow that installed docker-compose by hand needs a second look. Two behaviour differences are documented. Container names use a hyphen instead of an underscore (myapp-web-1 versus myapp_web_1), unless you pass --compatibility. And the version: key is ignored, with the warning “the attribute version is obsolete, it will be ignored”.

Migrate your scripts and add a shim

  • Inventory — grep -rnE 'docker-compose( |$)' /opt /etc/cron* /etc/systemd ~/.bashrc lists the calls to fix.
  • Scripts and Makefile — replace the hyphen with a space (docker compose up -d); the common subcommands stay the same.
  • cron and systemd — call /usr/bin/docker compose with an absolute path, because cron runs with a reduced PATH.
  • GitHub Actions — test your workflows with runs-on: ubuntu-26.04 before the switch, or pin ubuntu-24.04 while you fix them, as GitHub recommends.
  • Temporary shim — a two-line executable file /usr/local/bin/docker-compose, #!/bin/sh then exec docker compose "$@", catches forgotten calls. Docker also maintains compose-switch, which translates v1 syntax to v2.
  • Container names — replace hard-coded docker exec myapp_web_1 calls with docker compose exec web, which does not depend on the separator.
  • Verification — docker compose version answers, docker compose config passes without a warning, command -v docker-compose points only to the shim.

Advanced health checks: monitoring real service state

A well-configured health check goes beyond a simple curl: it measures the functional state of the service, not just network availability. For an HTTP API, test a lightweight business endpoint (/health or /ready) that returns 200 only if the database connection is established. For PostgreSQL, use pg_isready -U postgres rather than curl. For Redis, redis-cli ping is sufficient. Check the state of your containers with docker compose ps: the STATUS column shows healthy, unhealthy or starting. A container stuck in starting beyond start_period signals an initialization problem — consult docker compose logs service to find the cause. The start_period parameter is distinct from interval: it defines a grace window during which failures do not count toward retries, which is essential for services that need time to populate data or run schema migrations.

Docker secrets without Swarm: mounting files in /run/secrets/

Outside Swarm, Compose accepts file-based secrets. Declare the secret in the root-level secrets: section, pointing to a local file (file: ./secrets/db_password.txt), then reference it in the service with secrets: [db_password]. Docker mounts that file read-only in /run/secrets/db_password: the application reads it as an ordinary file, and its value appears neither in the environment variables shown by docker inspect nor in the logs. To rotate, version the secret: create db_password_v2 next to db_password_v1, make the service read v2, redeploy it alone with docker compose up -d --no-deps service, then delete v1 once the deployment is validated. The interruption is limited to recreating that one container, and the old secret stays available if you need to roll back.

Rollback: recovering from a failed deployment

Rolling back a Compose stack is prepared before the deployment, not after the incident. Keep the previous image tagged and available locally: add an explicit myapp:stable tag before each update. To roll back, put the old tag back in the Compose file (or in the .env if the version is a variable), then rerun docker compose up -d. Volumes are not touched by an image change, which makes an application rollback fast; a schema migration, however, needs its reverse migration ready before the deployment. For critical releases, split docker-compose.yml (stable version) and docker-compose.override.yml (candidate version): docker compose -f docker-compose.yml up -d ignores the override and returns to the stable state.

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: 6 common errors and their solutions

docker: unknown command: docker compose: the Compose plugin is not installed. On Ubuntu, install docker-compose-v2 from the archive, or docker-compose-plugin if Docker comes from Docker's official repository, then check with docker compose version. network not found: after a docker compose down, named networks are removed. If you restart and an external container tries to join that network, you get this error. Solution: declare shared networks as external: true or synchronize the restart of dependent stacks. port is already in use: another process (host nginx, an orphaned stack) occupies the port. Identify it with ss -tlnp | grep :PORT then stop it before restarting. OOM (Out Of Memory): the kernel kills a container without warning — look for OOMKilled: true in docker inspect container_id. Raise the memory limit (deploy.resources.limits.memory) or identify the leak with docker stats. permission denied on volume: the container process runs with a UID that has no access to the bind-mounted directory. Check the UID with docker compose exec service id, then adjust the host folder permissions (chown -R UID:GID /path) or add user: "UID:GID" to the service. Container restarting in a loop: docker compose logs --tail=50 service reveals the startup error. The most common causes are a missing environment variable, a missing config file or an unready dependency (solved by depends_on with service_healthy).

Backing up volumes without corruption

Copying files from a running PostgreSQL or MySQL volume with rsync or tar almost always produces a corrupted backup: the engine writes continuously during the copy, and data pages are captured at different checkpoints. The rule is to always run a SQL dump before the volume snapshot: pg_dump or mysqldump produce a consistent state you can archive or transfer. To automate this approach on a Docker VPS, tools like Restic and Offen Docker Backup orchestrate database freeze, SQL dump, encrypted snapshot and upload to remote storage. Document each named volume (step 5 of the checklist) and associate it with a tested restore strategy: a backup without a restore test is unverified data.

Conclusion

These ten settings turn a development Compose file into a stack that survives reboots, load spikes and unsupervised nights. Health checks, file-mounted secrets, a prepared rollback and a complete move to docker compose complete this foundation. None of it requires an additional tool: everything fits in the YAML and scripts you already have. Run through the checklist before each release, for example through a docker compose config review built into the deployment. You can then add a reverse proxy such as Traefik or Caddy, or an orchestration layer such as Coolify.

Deploy your Compose stack in production

A ServOrbit VPS gives you the root access, RAM and network control needed to run a hardened Docker Compose stack. Pick your configuration and deploy in minutes.