Learn

/

Docker Compose

Docker Compose

4 patterns

Service definitions, depends_on, profiles, and compose file structure. You'll hit this when your local dev stack has five services and you need them to start in the right order.

Avoid
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: secret

  app:
    build: .
    depends_on:
      - db
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: secret

  app:
    build: .
    depends_on:
      - db

Prefer
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: secret
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 5

  app:
    build: .
    depends_on:
      db:
        condition: service_healthy
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: secret
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 5

  app:
    build: .
    depends_on:
      db:
        condition: service_healthy
Why avoid

Plain depends_on only waits for the container to start, not for the service inside to be ready. Your app will crash with connection errors because PostgreSQL takes a few seconds to initialize after the container starts.

Why prefer

Using condition: service_healthy with a proper healthcheck ensures the app container only starts when the database is actually ready to accept connections. The pg_isready check verifies PostgreSQL is listening, not just that the container is running.

Docker docs: depends_on
Avoid
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: secret
    # No volume - data lost on restart
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: secret
    # No volume - data lost on restart

Prefer
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: secret
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: secret
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:
Why avoid

Without a volume, all database data lives in the container's writable layer. Running docker compose down or recreating the container deletes everything. Development data, test records, and migration state all vanish.

Why prefer

Named volumes persist data across container restarts and docker compose down. The pgdata volume is managed by Docker and survives container lifecycle events. Defining it in the top-level volumes section makes it explicit and shareable.

Docker docs: Compose volumes
Avoid
services:
  app:
    build: .
    ports:
      - "3000:3000"

  # Always starts, even when not needed
  mailhog:
    image: mailhog/mailhog
    ports:
      - "8025:8025"

  # Always starts, even when not needed
  adminer:
    image: adminer
    ports:
      - "8080:8080"
services:
  app:
    build: .
    ports:
      - "3000:3000"

  # Always starts, even when not needed
  mailhog:
    image: mailhog/mailhog
    ports:
      - "8025:8025"

  # Always starts, even when not needed
  adminer:
    image: adminer
    ports:
      - "8080:8080"

Prefer
services:
  app:
    build: .
    ports:
      - "3000:3000"

  mailhog:
    image: mailhog/mailhog
    ports:
      - "8025:8025"
    profiles:
      - debug

  adminer:
    image: adminer
    ports:
      - "8080:8080"
    profiles:
      - debug
services:
  app:
    build: .
    ports:
      - "3000:3000"

  mailhog:
    image: mailhog/mailhog
    ports:
      - "8025:8025"
    profiles:
      - debug

  adminer:
    image: adminer
    ports:
      - "8080:8080"
    profiles:
      - debug
Why avoid

Without profiles, every service starts on docker compose up, even debugging tools you rarely need. This wastes resources, clutters logs, and slows down your development workflow. Every developer pays the cost whether they need the tools or not.

Why prefer

Profiles let you define optional services that only start when explicitly requested with docker compose --profile debug up. This keeps the default docker compose up fast and lightweight while debug tools remain available on demand.

Docker docs: Profiles
Avoid
services:
  frontend:
    build: ./frontend
    ports:
      - "3000:3000"

  backend:
    build: ./backend

  db:
    image: postgres:16
    # All services on default network
    # Frontend can reach DB directly
services:
  frontend:
    build: ./frontend
    ports:
      - "3000:3000"

  backend:
    build: ./backend

  db:
    image: postgres:16
    # All services on default network
    # Frontend can reach DB directly

Prefer
services:
  frontend:
    build: ./frontend
    ports:
      - "3000:3000"
    networks:
      - frontend

  backend:
    build: ./backend
    networks:
      - frontend
      - backend

  db:
    image: postgres:16
    networks:
      - backend

networks:
  frontend:
  backend:
services:
  frontend:
    build: ./frontend
    ports:
      - "3000:3000"
    networks:
      - frontend

  backend:
    build: ./backend
    networks:
      - frontend
      - backend

  db:
    image: postgres:16
    networks:
      - backend

networks:
  frontend:
  backend:
Why avoid

The default Compose network puts all services on the same network. Any service can reach any other service directly. The frontend can bypass the backend and query the database, which is a security risk and an architectural violation.

Why prefer

Explicit networks isolate services by tier. The frontend can only reach the backend, and the backend can reach both frontend and database. The frontend cannot access the database directly. This follows the principle of least privilege and mirrors production topology.

Docker docs: Compose networks