Docker Compose Best Practices
Docker Compose is excellent at one thing: describing a small multi-container environment that developers and CI can start consistently. That is why it works so well for local stacks, integration tests, and disposable support tooling.
It is much less impressive when teams try to turn it into a long-term production orchestration platform. Compose is not Kubernetes, not Nomad, and not a substitute for proper deployment workflows.
Used in the right place, it removes setup drift. Used in the wrong place, it becomes a pile of shell scripts with YAML.
Treat Compose as an environment definition
The best mental model is simple: Compose defines how a set of containers should run together for development, testing, and short-lived operational tasks.
Good use cases:
- local app + database + cache
- integration test environments
- preview environments on a single host
- one-off jobs like migrations or seed loading
- reproducing bugs locally with real dependencies
Bad use cases:
- multi-node production orchestration
- autoscaling services
- complex rollout strategies
- long-term secret management
- service scheduling across hosts
If your production setup needs leader election, rolling updates, placement logic, or horizontal scaling policies, Compose is the wrong tool.
Use the v2 plugin and docker compose
The current standard interface is the Docker Compose v2 plugin:
docker compose versionPrefer docker compose ..., not the legacy docker-compose ... command.
Also stop adding the top-level version: field to new Compose files. It is obsolete in modern Compose and only creates confusion.
Keep the file layout boring
A simple structure is easier to maintain:
.
├── compose.yaml
├── compose.override.yaml
├── compose.ci.yaml
├── .env.example
├── app/
├── infra/
│ ├── postgres/
│ └── nginx/
└── scripts/A practical convention:
compose.yaml: shared baselinecompose.override.yaml: local developer overrides, auto-loadedcompose.ci.yaml: CI-specific changes.env.example: documented variables, not secrets
Do not create a dozen Compose files unless the environments are meaningfully different. Too many layers make docker compose config output hard to reason about.
Start from a minimal base file
Example:
services:
api:
build:
context: .
dockerfile: app/Dockerfile
command: ["./bin/api"]
environment:
APP_ENV: development
DATABASE_URL: postgres://app:app@db:5432/app?sslmode=disable
REDIS_URL: redis://redis:6379/0
ports:
- "8080:8080"
volumes:
- .:/workspace
working_dir: /workspace
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
db:
image: postgres:16
environment:
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD: app
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 5s
timeout: 3s
retries: 10
redis:
image: redis:7
command: ["redis-server", "--save", "", "--appendonly", "no"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
volumes:
postgres-data:There is no version: key, and there does not need to be one.
Use overrides for developer convenience, not architectural differences
compose.override.yaml is useful for local-only behavior:
- bind mounts
- extra debug ports
- live reload commands
- verbose logging
Example:
services:
api:
command: ["air", "-c", ".air.toml"]
environment:
LOG_LEVEL: debug
ports:
- "2345:2345"Then developers just run:
docker compose upFor CI:
docker compose -f compose.yaml -f compose.ci.yaml up --abort-on-container-exit --exit-code-from testUse overrides to change environment-specific behavior. Do not use them to hide basic contract differences between environments.
Be disciplined with .env files
Compose environment handling is a common source of confusion.
Three practical rules:
.envis for variable substitution in Compose files, not your entire secret strategy.env_file:injects variables into containers; it is separate from Compose parsing.- Commit
.env.example, not.envwith real credentials.
Example:
services:
api:
env_file:
- .env.local
environment:
APP_ENV: development
HTTP_PORT: ${HTTP_PORT:-8080}Things that surprise people:
- variables from
.envaffect${...}substitution in Compose env_filevalues go into the container environment- shell environment can override Compose substitution
- quoting and whitespace mistakes are easy to miss
Use docker compose config to inspect the fully rendered result before blaming Docker.
Secrets and config hygiene still matter in dev
A common excuse is “it is only local”. That is how credentials end up in Git history.
For local development:
- use fake or low-privilege credentials
- keep real secrets out of repo-tracked files
- prefer env vars or external secret sources over hard-coded values
- mount config files read-only when possible
Example:
services:
api:
environment:
STRIPE_API_BASE: https://api.stripe.com
STRIPE_API_KEY: ${STRIPE_API_KEY}
volumes:
- ./infra/api/config.dev.yaml:/workspace/config.yaml:roFor serious production secret management, Compose is not the answer. Use your actual secret manager and deployment platform.
Health checks are worth the trouble
Without health checks, “container started” is often mistaken for “service ready”.
Databases, queues, and apps with startup migrations are the usual offenders.
Example:
services:
api:
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/healthz"]
interval: 10s
timeout: 3s
retries: 10
start_period: 15sA good health check should be:
- cheap
- local to the container
- representative of readiness
- not dependent on flaky external networks
Do not point health checks at public third-party endpoints. That tells you more about the internet than your service.
depends_on helps with startup order, not application correctness
This point is worth being blunt about: depends_on is not a substitute for retry logic in your app.
Even with condition: service_healthy, there are still cases where:
- a dependency becomes unhealthy later
- schema migrations are not finished
- the app starts before downstream permissions or seed state exist
Compose can improve startup sequencing. It cannot guarantee end-to-end readiness forever.
Your application should still handle transient connection failures properly.
Prefer named volumes for persistent service data
Use named volumes for container-managed state like database files:
services:
db:
volumes:
- postgres-data:/var/lib/postgresql/data
volumes:
postgres-data:Use bind mounts for source code or config you want to edit from the host:
services:
api:
volumes:
- .:/workspaceAs a rule:
- named volumes for data durability
- bind mounts for developer workflows
Avoid bind-mounting database directories from your laptop unless you enjoy filesystem and permission edge cases.
Understand the default network before creating custom ones
Compose gives your project a default network automatically. Services can reach each other by service name:
db:5432redis:6379api:8080
That is enough for many local setups.
Create custom networks only when you need stronger isolation or deliberate topology.
Example:
services:
nginx:
image: nginx:1.27
networks:
- edge
api:
build: .
networks:
- edge
- backend
db:
image: postgres:16
networks:
- backend
networks:
edge:
backend:
internal: trueinternal: true is useful when you want a backend-only network that should not expose direct external connectivity.
Profiles are cleaner than comment-driven YAML
Profiles are handy when some services are optional.
Example:
services:
api:
build: .
mailhog:
image: mailhog/mailhog
profiles: ["dev"]
jaeger:
image: jaegertracing/all-in-one:1.57
profiles: ["observability"]Run the base stack:
docker compose upRun with MailHog:
docker compose --profile dev upRun with tracing tools:
docker compose --profile observability upThis is much better than asking people to comment and uncomment service blocks.
One-off jobs should be first-class services
Migrations, seed jobs, and admin tasks fit Compose well if you define them clearly.
Example:
services:
migrate:
build:
context: .
dockerfile: app/Dockerfile
command: ["./bin/migrate", "up"]
environment:
DATABASE_URL: postgres://app:app@db:5432/app?sslmode=disable
depends_on:
db:
condition: service_healthy
profiles: ["ops"]Run it explicitly:
docker compose --profile ops run --rm migrateThis is cleaner than pasting docker run ... incantations into wiki pages.
Logs should be easy to inspect and cheap to discard
For local and CI use, plain container stdout/stderr is usually enough.
Useful commands:
docker compose logs -f api
docker compose logs --tail=100 db
docker compose psIf a noisy service produces too much local log volume, cap it:
services:
api:
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"Do not over-engineer logging inside Compose. If you need centralized retention, indexing, and cross-host querying, you are already beyond Compose’s sweet spot.
Restart policies: useful locally, not a production strategy
Reasonable local defaults:
- no restart policy for short-lived jobs
unless-stoppedfor long-running local dependencies if helpful- avoid masking crash loops
Example:
services:
db:
image: postgres:16
restart: unless-stoppedBe careful: an aggressive restart policy can hide a broken service by endlessly restarting it while developers assume “the stack is up”.
In CI, restart policies are often counterproductive because you want failures to surface immediately.
Compose is good for CI and integration tests
Compose is a practical way to stand up dependencies for integration tests.
Example:
services:
test:
build:
context: .
dockerfile: app/Dockerfile
command: ["go", "test", "./...", "-count=1"]
environment:
DATABASE_URL: postgres://app:app@db:5432/app?sslmode=disable
depends_on:
db:
condition: service_healthy
db:
image: postgres:16
environment:
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD: app
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 5s
timeout: 3s
retries: 10Run it in CI with:
docker compose -f compose.yaml -f compose.ci.yaml up --build --abort-on-container-exit --exit-code-from testThen clean up:
docker compose down -vA few CI habits help a lot:
- use disposable named projects with
-p - always tear down networks and volumes after the run
- keep test fixtures deterministic
- avoid long-lived state between jobs
Useful commands people actually need
A few commands solve most day-to-day Compose problems:
docker compose up -d --build
docker compose down
docker compose down -v
docker compose ps
docker compose logs -f api
docker compose exec api sh
docker compose run --rm migrate
docker compose configdocker compose config is especially underrated. It shows the rendered configuration after merges and substitutions. Use it before debugging ghosts.
Anti-patterns worth rejecting
1. Using Compose as permanent production orchestration
Single host, tiny internal service, low change rate? Maybe. Anything more serious: use tooling built for production orchestration.
2. Stuffing real secrets into Compose files
If your compose.yaml contains production passwords, the problem is not YAML style.
3. Expecting depends_on to solve readiness forever
It only helps startup order. Applications still need retries and sane failure handling.
4. Bind-mounting everything
Mount source code, sure. Mount database internals, package caches, and every runtime directory from the host? Usually a mess.
5. Creating too many files and profiles
If nobody can explain which combination of three files and two profiles starts the stack correctly, the setup is overdesigned.
6. Relying on container names
Compose already gives stable service discovery by service name. Hard-coded container_name often creates naming collisions and reduces flexibility.
A short Compose checklist
When a Compose setup is in good shape, it usually looks like this:
docker compose, not legacy v1 commands- no obsolete
version:field - one base file, few overrides, clear purpose
- health checks on important dependencies
- named volumes for stateful services
- bind mounts only where developer workflow benefits
- profiles for optional tools
- no real secrets committed
- CI runs are disposable and cleaned up
- nobody is pretending this is a production scheduler
That is enough. Compose does not need to be clever. It needs to be predictable.