Docker Compose runs a multi-container app on one host from a single YAML file. For many self-hosted products (a web app, a database, a reverse proxy) that is all the orchestration they need. This guide gives you a compose.yaml we ran end to end on Docker Engine 29.8.2 with Docker Compose v5.5.0 on 17 October 2026, explains each production setting, and shows where Compose stops and Swarm or Kubernetes start.
Where Docker Compose fits in container orchestration
Container orchestration means starting, connecting, restarting and scaling containers for you. Kubernetes is the default answer at scale: CommandLinux puts its share of the container orchestration tools market at 92%, and, citing the CNCF 2024 Annual Survey, reports that 80% of organizations ran Kubernetes in production in 2024 while 93% were using, piloting or evaluating it. The market is growing fast too. Grand View Research values container orchestration at USD 1.7 billion in 2024 and projects USD 8.5 billion by 2030, a 31.8% CAGR.
Those numbers describe companies running clusters. A product that fits on one server pays for Kubernetes in control-plane upkeep and YAML it doesn't need. Compose is the smaller tool for that case.
Docker Compose vs Kubernetes for a self-hosted stack
| Question | Docker Compose | Kubernetes |
|---|---|---|
| Where it runs | One Docker host | A cluster of nodes |
| Config | One compose.yaml | Deployments, Services, Ingress and more |
| Scaling | Fixed replica count you set | Can scale automatically on CPU usage |
| Updating a service | Stops and recreates its containers | Replaces containers at a controlled rate |
| Survives a host failure | No | Handles failover across nodes |
If the last two rows are deal-breakers, read the section on deploying updates before you commit to Compose.
A production compose.yaml you can run
Install Docker Engine with the Compose plugin from the official install guide, then check both:
docker --version
docker compose version
Use docker compose with a space. Docker's Compose history page explains that docker-compose with a hyphen was Compose v1, the original Python tool. The supported versions today, Compose v2 and v5, are written in Go and invoked as docker compose.
Create a project folder with this compose.yaml:
services:
proxy:
image: traefik:v3.7
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --providers.docker.network=frontend
- --entrypoints.web.address=:80
ports:
- "80:80"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- frontend
restart: unless-stopped
web:
image: traefik/whoami:v1.12 # replace with your own app image
deploy:
replicas: 3
resources:
limits:
cpus: "0.50"
memory: 256M
labels:
- traefik.enable=true
- traefik.http.routers.web.rule=PathPrefix(`/`)
- traefik.http.routers.web.entrypoints=web
- traefik.http.services.web.loadbalancer.server.port=80
environment:
DATABASE_HOST: db
depends_on:
db:
condition: service_healthy
networks:
- frontend
- backend
restart: unless-stopped
db:
image: postgres:18
environment:
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
volumes:
- db-data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 10s
timeout: 5s
retries: 5
networks:
- backend
restart: unless-stopped
networks:
frontend:
backend:
internal: true
volumes:
db-data:
secrets:
db_password:
file: ./secrets/db_password.txt
Create the secret file, validate the config and start the stack:
mkdir -p secrets
openssl rand -base64 24 > secrets/db_password.txt
docker compose config -q
docker compose up -d --wait
curl -s localhost | grep Hostname
--wait returns once the services are running or healthy, according to the docker compose up reference. Repeat the curl and the hostname changes as Traefik spreads requests across the three web containers. In our run, six requests cycled through all three replicas in turn.
What each production setting does
restart: unless-stoppedrestarts a container whatever its exit code and stops only when you stop or remove the service (services reference).depends_onwithcondition: service_healthyholdswebback until the Postgreshealthcheckpasses.deploy.resources.limitscaps eachwebcontainer at half a CPU core and 256 MB of memory. The deploy reference gives the syntax, anddocker statsshowed the 256 MiB limit on every replica in our test.- The
backendnetwork isinternal: true, which the networks reference describes as externally isolated. The database sits only onbackend, so Traefik and the outside world can't reach it. - Pin image tags (
postgres:18,traefik:v3.7) so a laterdocker compose pullnever jumps to a new major version.
Mistakes this file avoids
- The file has no
version:line. Docker's version and name reference says the field is only informative and Compose warns that it is obsolete. Compose v5.5.0 printed exactly that warning when we addedversion: '3.8'. - The database volume is mounted. Declaring
db-dataundervolumes:without mounting it into the service keeps nothing. Since PostgreSQL 18 the official image expects the mount at/var/lib/postgresql; for 17 and below it is/var/lib/postgresql/data(postgres image docs). - The database password lives in a secret file instead of the YAML; the security section covers how that works.
Scaling replicas on one host
deploy.replicas sets how many containers of a service Compose keeps running. The catch is the host port. A host port can be bound by only one container, so a service with ports: - "8089:80" and replicas: 3 fails. When we tried it, one replica started and the second stopped with Bind for 0.0.0.0:8089 failed: port is already allocated.
The fix is the pattern in the file above: publish a port only on the reverse proxy, give the scaled service no ports:, and let the proxy find the replicas. Traefik reads routing from container labels (Traefik Docker provider docs). With exposedbydefault=false, only containers labelled traefik.enable=true get routed.
Replicas on one host keep the app answering if one container crashes. They don't help if the host itself goes down.
Deploying updates without dropping requests
docker compose up -d is not a rolling update. The up reference says that when a service's configuration or image has changed, Compose picks up the change "by stopping and recreating the containers". We changed one environment variable on the running stack and ran docker compose up -d while sending a request every 100 ms. All three web replicas were recreated together, and 13 of 120 requests failed (11 returned 404 from Traefik, one 502, one timed out).
What you can do about it:
- Redeploy only the service you changed. Docker's production guide uses
docker compose build webthendocker compose up --no-deps -d web, which leaves the database alone. The web replicas still restart together. - Deploy in a low-traffic window.
- Move to Docker Swarm when you need real rolling updates.
docker stack deployreads a Compose file (stack deploy docs) and honoursdeploy.update_config, whereorder: start-firststarts the new task before stopping the old one. Swarm updates one task at a time by default (rolling update tutorial). It ignoresbuild:, so push your images to a registry first.
Security checks before going live
- In plain Compose, a secret is a file mounted at
/run/secrets/<name>, and a service sees only the secrets it lists (Compose secrets guide). The official Postgres image readsPOSTGRES_PASSWORD_FILEfrom that path. Docker's guide warns that passwords in environment variables can leak into logs. Swarm secrets go further: they are encrypted in transit and at rest in the Raft log, and they exist only for Swarm services (Swarm secrets). Keepsecrets/out of git. - Traefik needs the Docker socket to discover containers, and its docs warn that unrestricted access to the Docker API is a security concern because a compromised Traefik could reach the host. The file mounts it read-only; for stricter setups, Traefik's docs list options such as a socket proxy.
- Run
trivy image postgres:18(and the same for your own image) before each release; vulnerability scanning is on by default (Trivy docs). - A container runs as root unless the image sets a
USERor the service setsuser:(services reference). Build your app image with a non-root user. - A published port without a host IP binds to
0.0.0.0and bypasses host firewall rules, per the same reference. Publish only the proxy, and put TLS on it before real traffic arrives.
Troubleshooting and backups
docker compose configrenders the configuration Compose will apply, merging every file you pass with-f(for example acompose.production.yamloverride). Add-qto validate without printing.docker compose psshows each container's health, anddocker compose logs -f webfollows one service's output.- Back up the database with
docker compose exec -T db pg_dump -U app app > app.sql. The-Tflag stops Compose from allocating a TTY, so the dump file stays clean. Copy the file off the host.
Frequently asked questions
Is Docker Compose suitable for production?
Yes, for apps that fit on one host and can accept a few seconds of disruption during deploys. You get restarts, health checks, resource limits, replicas and file-based secrets. You don't get failover to another machine or rolling updates; those need Swarm or Kubernetes.
What is the difference between docker-compose and docker compose?
docker-compose is Compose v1, written in Python. docker compose runs the supported versions (v2 and v5), written in Go. Use docker compose.
Do I still need the version key in docker-compose.yml?
No. Compose validates against the latest schema whatever the version: value says, and prints an "obsolete" warning when the key is present.
Why does deploy.replicas fail with "port is already allocated"?
Every replica tries to bind the same host port. Remove ports: from the scaled service and route to it through a reverse proxy such as Traefik.
Get help moving a stack to production
We have migrated 700+ websites without downtime. If your Compose stack needs deploys that don't drop requests, a CI pipeline or a move to Swarm, see our CI/CD and zero-downtime deployments service and ask for a free review.
