Docker Compose Best Practices
Introduction
Docker Compose remains the fastest way to run a multi-container application on a single host — but a lot of the advice still circulating describes a tool that no longer exists. Two things to unlearn first: Compose v2 ships as a Docker CLI plugin, invoked as docker compose (no hyphen) and installed with Docker Desktop or the docker-compose-plugin package — the standalone docker-compose v1 binary stopped receiving updates in July 2023, so any tutorial telling you to download and chmod it is outdated. And the version: key at the top of the file is obsolete: the merged Compose Specification replaced the old v2/v3 file formats, and current Compose prints a warning when the key is present. Name the file compose.yaml and start straight at services:.
A Working Example
services:
app:
image: ghcr.io/acme/shop-api:1.8.3
restart: unless-stopped
environment:
DATABASE_URL: postgres://shop:${DB_PASSWORD:?set in .env}@db:5432/shop
ports:
- "8080:8080"
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: shop
POSTGRES_DB: shop
POSTGRES_PASSWORD: ${DB_PASSWORD:?set in .env}
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U shop -d shop"]
interval: 5s
timeout: 3s
retries: 10
adminer:
image: adminer:4
ports:
- "8081:8080"
profiles: ["debug"]
volumes:
db-data:
The Practices That Matter
1. Pair depends_on With a Healthcheck
This is the classic Compose gotcha: depends_on by itself only controls start order — it does not wait for the dependency to be ready, so the app still races a Postgres that isn't accepting connections yet. The fix is what the example shows: give the dependency a healthcheck, and declare condition: service_healthy on the dependent service. Compose then holds the app back until the database actually answers.
2. Fail Fast on Environment Variables
Compose interpolates ${VARIABLES} from the shell and from a .env file next to the compose file. Use the ${VAR:?message} form for anything required, so a missing secret aborts startup with a clear error instead of producing a half-configured stack. Keep .env out of version control; commit a .env.example instead.
3. Use Profiles for Optional Services
Debug tooling like the adminer service above doesn't need to run on every docker compose up. Assign it a profile and opt in only when you want it: docker compose --profile debug up. Profiles keep one file serving several workflows without commenting blocks in and out.
4. Split Environments With Override Files
Keep compose.yaml as the neutral base. Compose automatically merges compose.override.yaml — the right home for dev-only concerns like build: sections, bind mounts, and exposed debugger ports. For other environments, pass files explicitly (docker compose -f compose.yaml -f compose.prod.yaml up -d) and inspect the merged result with docker compose config. This layering has replaced most historical uses of extends.
5. Pull Versioned Images in Production, Build Locally
A blanket "use build: instead of registry images" is backwards for anything beyond a laptop. CI should build the image once and push it to a registry; every deployment then pulls the same pinned tag — the example pins shop-api:1.8.3, never :latest. Reserve build: for the dev override file. Choosing and hardening that registry is its own topic — see our guide on where to store Docker images.
6. Use Named Volumes for State
Named volumes like db-data survive docker compose down and recreate cleanly, and they keep database internals out of your project directory. Reserve bind mounts for source code in development.
When to Graduate to Kubernetes
Compose is a single-host tool, and that's a feature: for one server it beats a cluster on simplicity every time. The honest triggers for moving on are needs Compose structurally can't meet — high availability across hosts, autoscaling, rolling deployments with automated rollback, and first-class secret management. If those are appearing on your roadmap, read our notes on running Kubernetes in real life before committing; the operational bill is real. Our DevOps team regularly helps teams decide which side of that line they're on — and plenty of production workloads are correctly still on Compose.