Contents

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 version

Prefer 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 baseline
  • compose.override.yaml: local developer overrides, auto-loaded
  • compose.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 up

For CI:

docker compose -f compose.yaml -f compose.ci.yaml up --abort-on-container-exit --exit-code-from test

Use 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:

  1. .env is for variable substitution in Compose files, not your entire secret strategy.
  2. env_file: injects variables into containers; it is separate from Compose parsing.
  3. Commit .env.example, not .env with real credentials.

Example:

services:
  api:
    env_file:
      - .env.local
    environment:
      APP_ENV: development
      HTTP_PORT: ${HTTP_PORT:-8080}

Things that surprise people:

  • variables from .env affect ${...} substitution in Compose
  • env_file values 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:ro

For 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: 15s

A 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:
      - .:/workspace

As 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:5432
  • redis:6379
  • api: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: true

internal: 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 up

Run with MailHog:

docker compose --profile dev up

Run with tracing tools:

docker compose --profile observability up

This 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 migrate

This 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 ps

If 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-stopped for long-running local dependencies if helpful
  • avoid masking crash loops

Example:

services:
  db:
    image: postgres:16
    restart: unless-stopped

Be 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: 10

Run it in CI with:

docker compose -f compose.yaml -f compose.ci.yaml up --build --abort-on-container-exit --exit-code-from test

Then clean up:

docker compose down -v

A 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 config

docker 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.