DevOps · Docker

Docker Compose in Production

Docker Compose in Production is the work that turns a deploy into a system. The deployment is one moment; the system is the next 18 months of uptime, incidents, and improvements.

John Kihiu12 min read

Compose gets dismissed as a local-dev-only tool, but for a single-host deployment — one VM running a handful of services — it's a legitimate production option, not a lesser one. The trade-off is honest: you give up multi-host scheduling and self-healing in exchange for a config file you can read top to bottom and a mental model with no scheduler between you and `docker ps`.

Restart policies and healthchecks are not optional

The default Compose behavior — no restart policy — means a crashed container just stays dead until someone notices. Every production service needs `restart: unless-stopped` (survives a reboot and crashes, but respects a deliberate `docker compose stop`) or `restart: on-failure` for jobs that should stay down after a clean exit. Pair that with a `healthcheck:` block so Docker actually knows the difference between "process is running" and "process is serving traffic" — a Node process can be alive and still deadlocked on a stuck database connection, and only a healthcheck catches that.

YAML · docker-compose.yml
services:
  api:
    image: registry.example.com/api:1.8.2
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/healthz"]
      interval: 15s
      timeout: 3s
      retries: 3
    depends_on:
      db:
        condition: service_healthy
    env_file: .env.production

  db:
    image: postgres:16
    restart: unless-stopped
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s

volumes:
  pgdata:

Secrets don't belong in the compose file

Committing a `docker-compose.yml` with inline environment values is the single most common Compose mistake in production. Use `env_file:` pointed at a file that's gitignored and deployed separately, or Docker Compose's `secrets:` top-level key backed by files with restricted permissions. Neither is as strong as a real secrets manager (Vault, AWS Secrets Manager), but both keep credentials out of version control, which is the bar that actually gets missed most often.

depends_on does not wait for "ready"

`depends_on` controls start order, not readiness — Compose starts the dependent container as soon as the dependency container starts, not once it's accepting connections. Use the `condition: service_healthy` form (shown above) combined with a real healthcheck, or your app will race the database on every cold start.

Updating a running service

`docker compose up -d` after pulling a new image tag recreates only the containers whose config changed, which is enough for a single-replica service but means a brief gap while the old container stops and the new one starts and passes its healthcheck. For anything that can't tolerate that gap, scale the service to two replicas behind a reverse proxy (Caddy, Traefik, or nginx) and roll them one at a time — Compose alone has no rolling-update primitive, so this is a manual sequence or a small script, not a flag.

When Compose stops being enough

The signal to move on is rarely "Compose is bad," it's "I need something Compose was never designed to do": scheduling across multiple hosts, automatic failover when a node dies, or horizontal autoscaling based on load. A single well-specified VM running Compose with healthchecks and restart policies handles a surprising amount of real traffic reliably — don't reach for Kubernetes to solve a problem you don't have yet.

ConcernCompose approach
Crash recoveryrestart: unless-stopped / on-failure
Dependency orderingdepends_on + condition: service_healthy
Secretsenv_file (gitignored) or secrets: block
Zero-downtime deployManual multi-replica rollover behind a proxy

Wrapping up

Compose in production is a legitimate choice for single-host workloads as long as you treat restart policies, healthchecks, and secret handling as required, not optional. The moment you need multi-host scheduling or self-healing across nodes, that's the actual signal to move to an orchestrator — not the number of services in the file.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.