← Back to DevBytes

Docker Compose Extends: Best Practices and Common Pitfalls

Docker Compose Extends: Best Practices and Common Pitfalls

What Is Docker Compose Extends?

Docker Compose extends is a mechanism for reusing service definitions across one or more Compose files. Instead of duplicating configuration blocks for similar services, you can define a base service once and then inherit, override, or merge properties in child services. This promotes DRY (Don't Repeat Yourself) principles in your container orchestration configurations.

Historically, Docker Compose offered a dedicated extends keyword (available in Compose file versions 2.1 through 2.4). In modern Compose (version 3.x and the current Compose Specification), the extends field was deprecated, and the recommended approach shifted to using multiple Compose files merged together via the -f CLI flag. However, as of 2023–2024, the Compose Specification has reintroduced and stabilized extends as a first-class feature, making both approaches viable depending on your Docker Compose version.

Why Extends Matters

Using extends properly gives you several concrete benefits:

How to Use Extends — The Two Approaches

Approach 1: The extends Keyword (Compose Specification 2.x and Modern Compose)

The extends field allows a service to inherit from another service defined either in the same file or in a separate YAML file. When inheriting from a different file, you specify the file path. The child service can override any inherited property or add new ones.

Basic syntax — same file inheritance:

# docker-compose.yml
services:
  # Base service — not meant to be run directly
  base-app:
    image: node:18-alpine
    environment:
      - NODE_ENV=development
      - LOG_LEVEL=debug
    volumes:
      - ./app:/src
    restart: unless-stopped

  # Derived service that extends base-app
  api:
    extends:
      service: base-app
    ports:
      - "3000:3000"
    environment:
      - PORT=3000

  # Another derived service
  worker:
    extends:
      service: base-app
    command: ["npm", "run", "worker"]
    environment:
      - PORT=5001

Cross-file inheritance:

# common/base-services.yml
services:
  base-db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: appuser
    volumes:
      - db-data:/var/lib/postgresql/data
    restart: unless-stopped

volumes:
  db-data:
# docker-compose.override.yml
services:
  postgres-primary:
    extends:
      file: common/base-services.yml
      service: base-db
    environment:
      POSTGRES_DB: myapp
    ports:
      - "5432:5432"

  postgres-read-replica:
    extends:
      file: common/base-services.yml
      service: base-db
    environment:
      POSTGRES_DB: myapp_replica
    ports:
      - "5433:5432"

When you run docker compose up, Docker Compose resolves all extends chains, merges configurations, and builds the final service definitions. Properties in the child take precedence — this is a shallow merge where entire mapping keys are replaced rather than deeply merged.

Approach 2: Multiple Compose Files with -f Flag

The multiple-file approach works by specifying a base Compose file plus one or more override files. Docker Compose merges them in the order provided, with later files taking precedence. This is universally supported across all Compose versions and is the most portable method.

# docker-compose.base.yml
services:
  web:
    image: nginx:alpine
    volumes:
      - ./static:/usr/share/nginx/html
    networks:
      - frontend

  backend:
    image: myapp:latest
    environment:
      - DEBUG=false
    networks:
      - frontend
      - backend-db

networks:
  frontend:
  backend-db:
# docker-compose.dev.yml
services:
  web:
    volumes:
      - ./src:/usr/share/nginx/html:ro  # override volumes
    ports:
      - "8080:80"

  backend:
    image: myapp:dev
    environment:
      - DEBUG=true
      - HOT_RELOAD=true
    volumes:
      - ./src:/app/src  # add new volume

Run the combined stack with:

docker compose \
  -f docker-compose.base.yml \
  -f docker-compose.dev.yml \
  up -d

Docker Compose reads files left-to-right. The docker-compose.dev.yml file overlays changes on top of docker-compose.base.yml. For list-type keys like volumes, the override file replaces the entire list unless you explicitly use merge-friendly syntax in newer Compose versions.

Understanding Merge Behavior

This is where many developers stumble. Docker Compose merges YAML documents using a specific strategy:

Here is a concrete example demonstrating the list replacement pitfall:

# base.yml
services:
  app:
    volumes:
      - data:/var/data
      - config:/etc/config

# override.yml
services:
  app:
    volumes:
      - logs:/var/log   # This REPLACES the entire list!

After merging, the app service has only logs:/var/log — the data and config volumes are gone. To preserve both, you must repeat the base volumes in the override file or use a different strategy like anchors.

Best Practices

1. Use YAML Anchors and Aliases for In-File Reuse

For configuration shared within a single file, YAML anchors are cleaner than extends. They allow deep merging and don't require splitting files.

# docker-compose.yml
services:
  backend: &backend-template
    image: myorg/backend:latest
    environment: &common-env
      DB_HOST: postgres
      REDIS_HOST: redis
      LOG_FORMAT: json
    healthcheck: &health-defaults
      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
      interval: 30s
      timeout: 5s
      retries: 3
    restart: unless-stopped

  api:
    <<: *backend-template
    ports:
      - "8080:8080"
    environment:
      <<: *common-env
      SERVICE_NAME: api-gateway

  worker-payments:
    <<: *backend-template
    command: ["node", "worker.js", "--queue=payments"]
    environment:
      <<: *common-env
      SERVICE_NAME: payment-worker

Anchors (&anchor-name) mark a block for reuse, and aliases (*anchor-name) insert it elsewhere. The <<: merge key performs a deep merge, so environment variables from the anchor are preserved when you add new ones.

2. Structure Files by Environment and Concern

Organize Compose files into layers that each serve a clear purpose:

project/
├── compose.base.yml          # Core services, networks, volumes
├── compose.dev.yml           # Development overrides
├── compose.staging.yml       # Staging-specific config
├── compose.prod.yml          # Production hardening
├── compose.observability.yml # Logging, monitoring sidecars
└── compose.test.yml          # CI/test-specific services

Run with explicit file ordering:

# Development
docker compose -f compose.base.yml -f compose.dev.yml up

# Production with observability
docker compose -f compose.base.yml -f compose.prod.yml -f compose.observability.yml up

3. Keep Base Files Generic — Never Environment-Specific

A base Compose file should define the structural blueprint: which services exist, which images they use by default, and how they connect via networks. It should not contain secrets, production resource limits, or host-specific volume mounts. Those belong in environment-specific override files.

# Good base.yml — generic and portable
services:
  app:
    image: myapp:${APP_TAG:-latest}
    environment:
      - NODE_ENV=${NODE_ENV:-production}
    networks:
      - backend

# Bad base.yml — environment-specific details leak in
services:
  app:
    image: myapp:1.2.3-production-fixed
    environment:
      - NODE_ENV=production
      - AWS_SECRET_ACCESS_KEY=sk-xxxxx  # NEVER put secrets in base files
    deploy:
      resources:
        limits:
          memory: 4G  # Production-specific resource limit

4. Document Your Extends Chain Explicitly

When using the extends keyword across multiple files, add comments in each file noting which other files are expected. This prevents confusion when new team members encounter the stack.

# This file extends common/postgres-base.yml
# Expected merge order: postgres-base.yml → this file
services:
  postgres:
    extends:
      file: common/postgres-base.yml
      service: postgres-base
    # Production-specific hardening
    environment:
      POSTGRES_LOG_STATEMENT: all

5. Validate Merged Output Before Deploying

Always inspect the fully resolved Compose configuration to verify your merges and extends produced the intended result:

# Print the fully merged configuration
docker compose -f compose.base.yml -f compose.prod.yml config

# Save it to a file for review
docker compose -f compose.base.yml -f compose.prod.yml config > resolved-stack.yml

The docker compose config command normalizes and resolves all extends, merges, variable substitutions, and prints the final YAML. Review it carefully — especially list fields like volumes and ports — before promoting changes to production.

6. Use COMPOSE_FILE Environment Variable for Consistency

Instead of typing long -f chains repeatedly, set the COMPOSE_FILE environment variable so all team members and CI pipelines use the same file sequence:

# .env file in project root
COMPOSE_FILE=compose.base.yml:compose.dev.yml:compose.observability.yml

Now simply running docker compose up automatically merges all three files in order. Different environments can have their own .env files.

Common Pitfalls

Pitfall 1: Assuming Deep Merge for Lists

The most frequent source of bugs is expecting Docker Compose to intelligently merge lists (volumes, ports, environment when defined as lists). It does not. The last file's list completely overwrites previous lists.

# base.yml
services:
  web:
    ports:
      - "80:80"
      - "443:443"

# override.yml — intending to ADD a debug port
services:
  web:
    ports:
      - "9229:9229"

# Result: ONLY port 9229 survives. 80 and 443 are gone.

Fix: Repeat all ports in the override, or switch to the dictionary form of ports (available in newer Compose versions) which merges more predictably:

# override.yml — explicitly listing all ports
services:
  web:
    ports:
      - "80:80"
      - "443:443"
      - "9229:9229"   # all three present

Pitfall 2: Circular or Deeply Nested Extends

The extends keyword can reference services that themselves extend others. While Compose resolves chains, deeply nested extends (more than 2–3 levels) become hard to debug and slow to resolve. Avoid creating chains like A extends B extends C extends D.

# Avoid this anti-pattern
services:
  d: &d
    image: alpine
  c:
    extends:
      service: d
  b:
    extends:
      service: c
  a:
    extends:
      service: b
    # What exactly does 'a' inherit? Hard to trace.

Pitfall 3: Extending Services with Conflicting Dependencies

When a base service defines depends_on and the extending service also defines depends_on, the child's list replaces the parent's. If the base service depended on a database that the child omits, the database may not start before the child service, causing race conditions.

# base.yml
services:
  base:
    depends_on:
      - postgres
      - redis

# child.yml
services:
  api:
    extends:
      service: base
    depends_on:
      - redis   # postgres dependency is LOST

Fix: Always explicitly include all required dependencies in the extending service, or use a shared anchor for the full dependency list.

Pitfall 4: Inconsistent File Paths in Extends

When using cross-file extends with the file parameter, paths are relative to the current Compose file, not the project root. This causes confusion when override files reference base files in different directories.

project/
├── docker-compose.yml
├── services/
│   └── base.yml
└── overrides/
    └── dev.yml

# overrides/dev.yml — WRONG path reference
services:
  app:
    extends:
      file: base.yml   # Looks for overrides/base.yml, not services/base.yml!
      service: base-app

# Correct reference uses relative path from dev.yml's location:
# file: ../services/base.yml

Pitfall 5: Secrets and Sensitive Data Propagation

Extends and merges copy configuration blocks verbatim. If a base service defines environment variables with default values that look like secrets (even placeholders), they propagate to every derived service and appear in docker compose config output, which is often committed to logs or CI artifacts.

# base.yml — DO NOT do this
services:
  base:
    environment:
      - DB_PASSWORD=changeme   # Appears in plain text in config dumps

# Instead, use variable substitution with no default in base:
services:
  base:
    environment:
      - DB_PASSWORD=${DB_PASSWORD:?required}   # Fails if not set

Pitfall 6: Forgetting That extends Copies Service Definitions Statically

The extends keyword copies the base service definition at parse time. It does not create a dynamic inheritance relationship. If you later change the base service and restart, child services do not automatically pick up changes unless you recreate the entire stack. This is different from how Docker Compose profiles or multiple -f files behave during a hot reload.

Real-World Example: Multi-Environment Microservice Stack

Consider a microservice application with five services that need different configurations for local development, CI testing, and production deployment.

Base Compose file:

# compose.base.yml
services:
  gateway: &gateway-template
    image: nginx:${NGINX_VERSION:-alpine}
    networks:
      - ingress
    restart: unless-stopped

  auth-service: &service-template
    image: myorg/auth:${AUTH_TAG:-latest}
    environment: &svc-env
      LOG_LEVEL: info
      TRACING_ENABLED: "true"
    networks:
      - backend
    restart: unless-stopped

  inventory-service:
    <<: *service-template
    image: myorg/inventory:${INV_TAG:-latest}
    environment:
      <<: *svc-env
      DB_NAME: inventory_db

  orders-service:
    <<: *service-template
    image: myorg/orders:${ORD_TAG:-latest}
    environment:
      <<: *svc-env
      DB_NAME: orders_db

  redis:
    image: redis:7-alpine
    networks:
      - backend

networks:
  ingress:
    driver: bridge
  backend:
    driver: bridge

Development override:

# compose.dev.yml
services:
  gateway:
    ports:
      - "80:80"
      - "443:443"

  auth-service:
    environment:
      LOG_LEVEL: debug
      AUTH_MOCK_MODE: "true"
    volumes:
      - ./services/auth/src:/app/src
    ports:
      - "9001:9001"

  inventory-service:
    environment:
      LOG_LEVEL: debug
      DB_HOST: localhost
    volumes:
      - ./services/inventory/src:/app/src

  orders-service:
    environment:
      LOG_LEVEL: debug
      DB_HOST: localhost
    volumes:
      - ./services/orders/src:/app/src

  redis:
    ports:
      - "6379:6379"

Production override:

# compose.prod.yml
services:
  gateway:
    deploy:
      replicas: 2
      resources:
        limits:
          memory: 256M

  auth-service:
    environment:
      LOG_LEVEL: warn
      TRACING_SAMPLING_RATE: "0.1"
    deploy:
      replicas: 3

  inventory-service:
    deploy:
      replicas: 2

  orders-service:
    deploy:
      replicas: 3

  redis:
    deploy:
      replicas: 1
    volumes:
      - redis-persist:/data

volumes:
  redis-persist:

Run commands for each environment:

# Local development
docker compose -f compose.base.yml -f compose.dev.yml up --build

# Production (with build args)
NGINX_VERSION=stable AUTH_TAG=2.1.3 \
  docker compose -f compose.base.yml -f compose.prod.yml up -d

# Validate production config before deploy
docker compose -f compose.base.yml -f compose.prod.yml config > prod-resolved.yml

Conclusion

Docker Compose extends — whether through the extends keyword or the multiple-file merge strategy — is a powerful tool for building maintainable, DRY container configurations. The key to using it successfully is understanding the merge semantics: scalar overrides are straightforward, but lists and mappings replace rather than combine. By structuring your Compose files into layered bases and environment-specific overrides, using YAML anchors for in-file reuse, validating merged output with docker compose config, and vigilantly avoiding the pitfalls of list overwrites and secret leakage, you can create a Compose architecture that scales cleanly from a single developer's laptop to a full production deployment. Start small, validate often, and let the compose file layering work for you rather than against you.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles