Docker Compose

The modern Compose spec in practice: compose.yaml layout, depends_on with healthchecks, env_file layering, profiles, extends, dev/prod override files, and common pitfalls with networks and bind-mount permissions.

If you only remember six things
  • The file is compose.yaml and the command is docker compose (no hyphen). The old docker-compose binary and v1/v2 version: keys are gone from the spec.
  • depends_on alone only waits for the container to start, not for the service to be ready. Combine it with condition: service_healthy and a real healthcheck.
  • Environment precedence is: shell > --env-file > env_file: in the service > environment: in the service > Dockerfile ENV. Know which one you are touching.
  • compose.override.yaml is read automatically for dev. Prod uses explicit -f compose.yaml -f compose.prod.yaml.
  • Profiles keep opt-in services (admin UIs, seed jobs) out of the default startup.
  • Named volumes are almost always the right answer for stateful containers; bind mounts are for code you are actively editing.

v3 vs the modern Compose spec

The version: "3.8" you remember is a Docker Swarm artefact. The current reality:

Services, networks, volumes

The three top-level keys you will use 99% of the time:

services:
  web:
    image: nginx:1.27-alpine
    ports:
      - "8080:80"
    networks: [frontend]
    depends_on:
      app:
        condition: service_healthy

  app:
    build: ./app              # or image: myrepo/app:tag
    networks: [frontend, backend]
    environment:
      DATABASE_URL: postgres://app:${DB_PASS}@db:5432/app
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost:3000/healthz"]
      interval: 10s
      timeout: 3s
      retries: 5
      start_period: 20s

  db:
    image: postgres:16-alpine
    networks: [backend]
    volumes:
      - pgdata:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${DB_PASS}
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 5s
      timeout: 3s
      retries: 10

networks:
  frontend: {}
  backend:
    internal: true            # no egress to the outside world

volumes:
  pgdata: {}

Three principles hiding in that file:

depends_on and service_healthy

The short form does not do what you think:

services:
  app:
    depends_on:
      - db            # starts db first, but does NOT wait for db to be ready

The kernel has started Postgres, but Postgres may still be running crash-recovery. Your app will get connection refused or FATAL: database system is starting up. Use the long form with a condition:

services:
  app:
    depends_on:
      db:
        condition: service_healthy      # wait until db's healthcheck passes
      migrations:
        condition: service_completed_successfully   # wait until a one-shot job exits 0

The conditions you can pass are:

Environment: env_file, environment, precedence

services:
  app:
    env_file:
      - .env.defaults    # checked in, sensible defaults
      - .env.local       # not checked in, operator overrides
    environment:
      LOG_LEVEL: ${LOG_LEVEL:-info}   # falls back to 'info' if unset

Precedence from lowest to highest (higher wins):

  1. Dockerfile ENV
  2. env_file: (later files override earlier)
  3. environment: in the service
  4. --env-file on the CLI
  5. Shell environment at docker compose up time
Interpolation vs. environment. ${X} in compose.yaml is interpolated at parse time from the shell (and .env next to the file). environment: {X: ...} is passed to the container at run time. Two different layers.

Profiles: opt-in services

Profiles keep admin tooling (pgAdmin, a seeder, a debug shell) in the Compose file without starting them on docker compose up.

services:
  pgadmin:
    image: dpage/pgadmin4
    profiles: [tools]
    depends_on: [db]
    environment:
      PGADMIN_DEFAULT_EMAIL: a@b.c
      PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASS}

  seed:
    build: ./seed
    profiles: [seed]
    depends_on:
      db: { condition: service_healthy }
docker compose up                    # normal stack, pgadmin & seed skipped
docker compose --profile tools up    # add pgadmin
docker compose run --rm seed         # run the one-shot seed job

extends and DRY

When three services share the same healthcheck, logging, or restart policy, pull the common bits into a fragment and extends::

# compose.common.yaml
services:
  _base:
    restart: unless-stopped
    logging:
      driver: journald
      options: { tag: "{{.Name}}" }
    deploy:
      resources:
        limits: { memory: 512M }
# compose.yaml
services:
  app:
    extends:
      file: compose.common.yaml
      service: _base
    build: ./app

  worker:
    extends:
      file: compose.common.yaml
      service: _base
    build: ./worker

For simple overrides within a single file, YAML anchors (&name/*name) are often clearer than extends.

Override files for dev vs prod

Compose reads compose.yaml and compose.override.yaml automatically. Convention:

# compose.override.yaml — dev only
services:
  app:
    build:
      context: ./app
      target: dev              # stop at a dev stage of the Dockerfile
    volumes:
      - ./app/src:/app/src      # live-reload bind mount
    environment:
      LOG_LEVEL: debug
    ports:
      - "9229:9229"             # node inspector
docker compose up                                   # uses override, dev mode
docker compose -f compose.yaml -f compose.prod.yaml up -d   # prod

Tuning healthchecks

A bad healthcheck is worse than none. The four knobs:

healthcheck:
  test: ["CMD-SHELL", "pg_isready -U app -d app"]
  interval: 5s         # probe cadence when the container is up
  timeout: 3s          # each probe's timeout; must be < interval
  retries: 10          # consecutive failures before going 'unhealthy'
  start_period: 20s    # grace window: failures here don't count
                       # (use for DBs doing crash-recovery or JVM warmup)

Rules of thumb:

Common pitfalls

Docker network IP clashes

Docker's default bridge is 172.17.0.0/16. Compose makes per-project networks in 172.18.0.0/16 and up. If your corporate VPN uses the same range, containers can't reach the VPN (or vice-versa). Fix in /etc/docker/daemon.json:

{
  "default-address-pools": [
    { "base": "10.201.0.0/16", "size": 24 }
  ]
}

Restart dockerd, docker compose down/up.

Bind-mount permissions

On Linux, a container writing to a host-mounted ./data will create files owned by the container's UID, not yours. Under rootless Podman, that UID maps into your sub-UID range and looks like a four-digit host UID you don't recognise. Fixes:

Volume vs bind: ownership surprises

Named volumes are pre-chowned to the container's first-run user. Bind mounts are not — whatever is already on the host wins.

Don't commit .env

Compose reads .env automatically for interpolation. Put secrets there and you will commit them. Either use .env.defaults + .env.local and gitignore .env.local, or pull secrets from your cloud's secret manager via entrypoint at container start.

A complete example: nginx + app + postgres

Works end-to-end. App is a trivial Node service behind nginx with a Postgres DB and a one-shot migrations container.

# compose.yaml
services:
  nginx:
    image: nginx:1.27-alpine
    ports: ["80:80"]
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      app: { condition: service_healthy }
    networks: [edge]

  app:
    build: ./app
    environment:
      DATABASE_URL: postgres://app:${DB_PASS}@db:5432/app
      LOG_LEVEL: ${LOG_LEVEL:-info}
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/healthz"]
      interval: 10s
      timeout: 3s
      retries: 5
      start_period: 15s
    depends_on:
      migrate: { condition: service_completed_successfully }
    networks: [edge, data]

  migrate:
    build: ./app
    command: ["npm", "run", "migrate"]
    environment:
      DATABASE_URL: postgres://app:${DB_PASS}@db:5432/app
    depends_on:
      db: { condition: service_healthy }
    networks: [data]
    restart: "no"

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${DB_PASS}
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 10s
    networks: [data]

networks:
  edge: {}
  data:
    internal: true    # DB never touches the outside world

volumes:
  pgdata: {}
DB_PASS=changeme docker compose up -d
docker compose ps
docker compose logs -f app
docker compose exec db psql -U app -d app
docker compose down       # keeps the volume
docker compose down -v    # wipes the db

Next: when one host isn't enough, escalate to Kubernetes Light. For the "why" under all of this, see Containers 101.