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.
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` 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.
| Concern | Compose approach |
|---|---|
| Crash recovery | restart: unless-stopped / on-failure |
| Dependency ordering | depends_on + condition: service_healthy |
| Secrets | env_file (gitignored) or secrets: block |
| Zero-downtime deploy | Manual 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.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.