← Back to DevBytes

Docker Compose Extends: Production Guide

Understanding Docker Compose Extends

The extends directive in Docker Compose allows you to reuse configuration from another service definition, either within the same compose file or from an entirely separate file. Think of it as configuration inheritance for your container services. Instead of copying and pasting the same environment variables, volumes, or health checks across multiple services, you define them once in a base service and then extend that base wherever needed.

At its core, extends solves the DRY (Don't Repeat Yourself) problem in compose files. It lets you declare a canonical service configuration and then create variations that inherit everything from the parent while overriding only what needs to change. This is especially powerful in production environments where you may have dozens of microservices sharing common logging, monitoring, networking, or security configurations.

The feature is fully supported in modern Docker Compose (v2.3+) and works with both the standalone docker-compose CLI and the integrated docker compose plugin. It operates at the service level, meaning you extend a specific named service, pulling in its entire definition as a starting point for your new service.

Why Extends Matters in Production

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

In development environments, duplication in compose files is annoying but tolerable. In production, it becomes a genuine liability. Here's why extends is critical for production-grade Docker deployments:

Without extends, teams often resort to massive, repetitive compose files or fragile templating scripts. Both approaches scale poorly and introduce subtle bugs when one service accidentally diverges from the production standard.

Basic Syntax and Usage

Service-Level Extends (Same File)

The simplest form of extends references another service defined in the same compose file. You specify the service name you want to inherit from, and Docker Compose pulls in that service's entire configuration as the base for the new service.

# docker-compose.yml
services:
  # Base service — not necessarily runnable on its own
  app-base:
    image: myapp:latest
    environment:
      - LOG_LEVEL=info
      - METRICS_ENABLED=true
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    restart: unless-stopped
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

  # Production API service extending the base
  api:
    extends:
      service: app-base
    ports:
      - "8080:8080"
    environment:
      - APP_MODE=production
    deploy:
      replicas: 3
      resources:
        limits:
          cpus: '2'
          memory: 4G

  # Worker service extending the same base
  worker:
    extends:
      service: app-base
    command: ["celery", "-A", "app", "worker"]
    environment:
      - APP_MODE=worker
    deploy:
      replicas: 2
      resources:
        limits:
          cpus: '1'
          memory: 2G

In this example, both api and worker inherit the app-base image, environment variables, volume mount, restart policy, and logging configuration. Each then adds or overrides only what makes it unique. The app-base service itself doesn't need to be deployed; it serves purely as a configuration template. When you run docker compose up, only api and worker are started unless you explicitly include the base.

File-Level Extends (External File)

For larger projects, you'll want to place the base service definition in a separate file that can be shared across multiple compose files. Use the file property alongside service to point to an external compose file.

# base-services.yml (shared foundation)
services:
  production-base:
    image: ${BASE_IMAGE:-myorg/app:stable}
    init: true
    restart: unless-stopped
    logging:
      driver: awslogs
      options:
        awslogs-group: "/production/${COMPOSE_PROJECT_NAME}"
        awslogs-region: "us-east-1"
        awslogs-stream-prefix: "ecs"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 40s
    networks:
      - production-net
# docker-compose.yml (production deployment)
services:
  payment-service:
    extends:
      file: base-services.yml
      service: production-base
    ports:
      - "8081:8080"
    environment:
      - SERVICE_NAME=payment
      - DB_URL=${PAYMENT_DB_URL}

  notification-service:
    extends:
      file: base-services.yml
      service: production-base
    ports:
      - "8082:8080"
    environment:
      - SERVICE_NAME=notification
      - DB_URL=${NOTIFICATION_DB_URL}
    deploy:
      replicas: 5

networks:
  production-net:
    driver: overlay
    attachable: true

Here, both payment-service and notification-service pull their foundation from base-services.yml. The base file defines the image (with a variable default), init process, restart policy, CloudWatch logging, health check, and network. Each concrete service then layers on ports, environment-specific variables, and deployment parameters. The base file can be version-controlled separately and even distributed as an internal "production template" for all teams.

Combining Extends with Direct Overrides

When you extend a service, you can override any property from the parent. Docker Compose performs a smart merge: for mappings (like environment, labels, deploy), the child's values are merged on top of the parent's. For sequences (like volumes, ports, expose), the child's list replaces the parent's entirely unless you explicitly re-declare items. For scalar values (like image, command, working_dir), the child's value wins.

# base.yml
services:
  default-service:
    image: nginx:alpine
    environment:
      FOO: bar
      BAZ: qux
    volumes:
      - /data/base:/data
    deploy:
      resources:
        limits:
          cpus: '0.5'
          memory: 256M
# override.yml
services:
  my-service:
    extends:
      file: base.yml
      service: default-service
    environment:
      BAZ: overridden        # Overrides BAZ, keeps FOO
      NEW_VAR: added         # Adds new variable
    volumes:
      - /data/custom:/data   # Replaces the entire volumes list
    deploy:
      resources:
        limits:
          cpus: '2'          # Overrides CPU limit, memory stays 256M

The resulting effective configuration for my-service will have FOO=bar (inherited), BAZ=overridden (overridden), NEW_VAR=added (added), the custom volume mount, 2 CPUs, and 256M memory. Understanding this merge behavior is crucial for avoiding unintended configuration loss, especially with lists like volumes and ports.

Production Patterns

Pattern 1: Base Template + Environment Overlays

This is the most common and powerful production pattern. You maintain a single base compose file that defines the canonical shape of every service, then create thin override files for each environment (production, staging, qa, development) that only specify what differs.

# compose/base.yml
services:
  app-template:
    image: myorg/app:${APP_VERSION:-latest}
    init: true
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
      interval: 30s
      timeout: 5s
      retries: 3
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "5"
    networks:
      - app-net

networks:
  app-net:
    driver: bridge
# compose/production.yml
services:
  web:
    extends:
      file: base.yml
      service: app-template
    ports:
      - "80:8080"
    environment:
      - ENV=production
      - LOG_LEVEL=warn
      - DB_HOST=${PROD_DB_HOST}
    deploy:
      replicas: 6
      resources:
        limits:
          cpus: '4'
          memory: 8G
    logging:
      driver: splunk
      options:
        splunk-token: ${SPLUNK_TOKEN}
        splunk-url: https://splunk.internal:8088

  worker:
    extends:
      file: base.yml
      service: app-template
    command: ["celery", "-A", "app", "worker", "-Q", "high_priority"]
    environment:
      - ENV=production
      - LOG_LEVEL=warn
      - DB_HOST=${PROD_DB_HOST}
    deploy:
      replicas: 4
      resources:
        limits:
          cpus: '2'
          memory: 4G
# compose/staging.yml
services:
  web:
    extends:
      file: base.yml
      service: app-template
    ports:
      - "8080:8080"
    environment:
      - ENV=staging
      - LOG_LEVEL=debug
      - DB_HOST=${STAGING_DB_HOST}
    deploy:
      replicas: 2
      resources:
        limits:
          cpus: '1'
          memory: 2G

  worker:
    extends:
      file: base.yml
      service: app-template
    command: ["celery", "-A", "app", "worker"]
    environment:
      - ENV=staging
      - LOG_LEVEL=debug
      - DB_HOST=${STAGING_DB_HOST}
    deploy:
      replicas: 1

To deploy to a specific environment, you specify the appropriate compose file:

# Deploy to production
docker compose -f compose/base.yml -f compose/production.yml up -d

# Deploy to staging
docker compose -f compose/base.yml -f compose/staging.yml up -d

Notice that extends handles the service-level inheritance, while the -f flags handle file-level composition. The two mechanisms complement each other beautifully: extends keeps individual services DRY, and multiple -f files keep environments DRY.

Pattern 2: Service Family Extensions

In microservice architectures, you often have families of similar services — HTTP APIs, gRPC endpoints, background workers, cron jobs — that share most configuration but differ in command, ports, or resource allocation. A single base service can spawn an entire family.

# family-base.yml
services:
  microservice-base:
    image: ${IMAGE_REPO}/core-service:${CORE_VERSION}
    init: true
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "pgrep -f '${SERVICE_BINARY}' || exit 1"]
      interval: 20s
      timeout: 5s
      retries: 3
      start_period: 30s
    environment:
      - OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
      - OTEL_SERVICE_NAME=${SERVICE_NAME}
      - CONFIG_PATH=/etc/app/config.yaml
    volumes:
      - config-volume:/etc/app:ro
    networks:
      - service-mesh
    logging:
      driver: fluentd
      options:
        fluentd-address: tcp://fluentd-aggregator:24224
        tag: "microservice.${SERVICE_NAME}"
# production-services.yml
services:
  # HTTP API Gateway
  api-gateway:
    extends:
      file: family-base.yml
      service: microservice-base
    ports:
      - "443:8443"
    environment:
      - SERVICE_NAME=api-gateway
      - SERVICE_BINARY=api-gateway
    deploy:
      replicas: 4

  # User Service (gRPC)
  user-service:
    extends:
      file: family-base.yml
      service: microservice-base
    ports:
      - "50051:50051"
    environment:
      - SERVICE_NAME=user-service
      - SERVICE_BINARY=user-svc
    deploy:
      replicas: 3

  # Order Service (gRPC)
  order-service:
    extends:
      file: family-base.yml
      service: microservice-base
    ports:
      - "50052:50051"
    environment:
      - SERVICE_NAME=order-service
      - SERVICE_BINARY=order-svc
    deploy:
      replicas: 3

  # Background Scheduler
  scheduler:
    extends:
      file: family-base.yml
      service: microservice-base
    command: ["/usr/local/bin/scheduler", "--interval", "60"]
    environment:
      - SERVICE_NAME=scheduler
      - SERVICE_BINARY=scheduler
    deploy:
      replicas: 1

volumes:
  config-volume:
    driver: local

Every service automatically gets OpenTelemetry instrumentation, Fluentd log shipping, health checks, and the shared config volume. Adding a new microservice to the family requires only a handful of lines — ports, a service name, and a replica count. The production infrastructure concerns (observability, logging, health monitoring) are inherited and cannot be accidentally omitted.

Pattern 3: Stack Composition with Shared Infrastructure

Production stacks often include shared infrastructure services (databases, message brokers, caches) alongside application services. You can use extends to standardize these infrastructure services too, ensuring consistent security hardening, backup configurations, and monitoring across all instances.

# infra-base.yml
services:
  database-base:
    init: true
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
      interval: 10s
      timeout: 5s
      retries: 5
    volumes:
      - db-data:/var/lib/postgresql/data
    logging:
      driver: journald
      options:
        tag: "database.${DB_ROLE}"
    deploy:
      resources:
        limits:
          cpus: '4'
          memory: 8G

  cache-base:
    image: redis:7-alpine
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 3
    volumes:
      - cache-data:/data
    logging:
      driver: journald
# stack-production.yml
services:
  # Primary write database
  db-primary:
    extends:
      file: infra-base.yml
      service: database-base
    image: postgres:16-alpine
    environment:
      - DB_ROLE=primary
      - DB_USER=${PROD_DB_USER}
      - DB_NAME=${PROD_DB_NAME}
      - POSTGRES_PASSWORD=${PROD_DB_PASSWORD}
    ports:
      - "5432:5432"

  # Read replica
  db-replica:
    extends:
      file: infra-base.yml
      service: database-base
    image: postgres:16-alpine
    environment:
      - DB_ROLE=replica
      - DB_USER=${PROD_DB_USER}
      - DB_NAME=${PROD_DB_NAME}
      - POSTGRES_PASSWORD=${PROD_DB_PASSWORD}
    ports:
      - "5433:5432"

  # Session cache
  session-cache:
    extends:
      file: infra-base.yml
      service: cache-base
    ports:
      - "6379:6379"

  # Rate-limiting cache
  rate-limit-cache:
    extends:
      file: infra-base.yml
      service: cache-base
    ports:
      - "6380:6379"

Both database instances inherit the same health check, restart policy, logging driver, and resource baseline. Both cache instances get the same Redis image, health check, and volume setup. Consistency across infrastructure reduces production incidents caused by configuration mismatches between primary and replica databases or between different cache instances.

Best Practices for Production Extends

1. Keep Base Files Environment-Agnostic

A base file should never contain environment-specific values like production hostnames, staging credentials, or resource allocations tuned for a particular cluster. Keep bases purely structural — the image reference (with variables), health check mechanics, logging infrastructure, restart policies, and init settings. Environment-specific values belong exclusively in override files. This separation ensures that a base file change (like updating a health check interval) can be rolled out confidently without worrying about accidentally promoting staging configurations to production.

# Good: base.yml
services:
  app-base:
    image: ${IMAGE}:${TAG}
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:${HEALTH_PORT}/health"]
      interval: 30s
      timeout: 5s
      retries: 3

# Good: production.yml
services:
  app:
    extends:
      file: base.yml
      service: app-base
    environment:
      - HEALTH_PORT=8080
      - DB_HOST=prod-db.internal
    deploy:
      replicas: 6
# Bad: base.yml with hardcoded production values
services:
  app-base:
    environment:
      - DB_HOST=prod-db.internal    # Environment-specific!
    deploy:
      replicas: 6                   # Environment-specific!

2. Use Variables Liberally in Base Files

Base files should use ${VARIABLE} syntax extensively for anything that might vary between environments or services. This creates a clean contract between the base and the overrides. Variables not set in the override can have defaults specified directly in the base file using ${VARIABLE:-default} syntax.

# base.yml
services:
  service-base:
    image: ${IMAGE_REPO:-docker.io/myorg}/app:${APP_VERSION:-latest}
    environment:
      - LOG_LEVEL=${LOG_LEVEL:-info}
      - METRICS_PORT=${METRICS_PORT:-9090}
    deploy:
      resources:
        limits:
          cpus: ${CPU_LIMIT:-'1'}
          memory: ${MEMORY_LIMIT:-1G}

3. Never Extend from a Deployed Service

Create dedicated "template" or "base" services that exist solely for extension. Do not extend from a service that is itself deployed in the same compose file. This creates confusing dependency chains and makes it unclear whether the base service is meant to run or just serve as a template. Template services typically lack ports, have no deployment configuration, and may even use a lightweight placeholder image.

# Good: dedicated template
services:
  app-template:       # Not deployed, only extended
    image: ${IMAGE}
    restart: unless-stopped

  web:
    extends:
      service: app-template
    ports: "80:8080"

# Avoid: extending a running service
services:
  web:                # This runs AND is extended by worker — confusing
    image: myapp
    ports: "80:8080"

  worker:
    extends:
      service: web    # Extends web, inherits ports it doesn't need
    command: ["celery", "worker"]

4. Document the Extension Contract

Maintain a README or inline comments that clearly document what each base service provides and what extending services are expected to override. This is especially important when base files are shared across teams. Document required variables, expected override points, and any inherited volumes or networks that extending services must be compatible with.

# base.yml — Production Microservice Template
# 
# Provides:
#   - Standardized health check at /health on port 8080
#   - JSON file logging with rotation (10MB, 5 files)
#   - OpenTelemetry OTLP exporter configuration
#   - unless-stopped restart policy with tini init
#
# Requires from extending service:
#   - SERVICE_NAME environment variable
#   - ports mapping (at minimum, expose port 8080)
#   - deploy resources appropriate to the service tier
#
# Optional overrides:
#   - LOG_LEVEL (default: info)
#   - OTEL_EXPORTER_OTLP_ENDPOINT (default: otel-collector:4317)

services:
  microservice-base:
    image: ${IMAGE_REPO}/service:${VERSION}
    init: true
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
      interval: 30s
      timeout: 5s
      retries: 3
    environment:
      - OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_ENDPOINT:-http://otel-collector:4317}
      - OTEL_SERVICE_NAME=${SERVICE_NAME}
      - LOG_LEVEL=${LOG_LEVEL:-info}
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "5"

5. Validate Merged Configurations Before Deployment

Always inspect the fully resolved configuration before deploying to production. Docker Compose provides the config command that renders the complete, merged compose file after all extends, variables, and overrides are resolved.

# Render the fully merged configuration
docker compose -f base.yml -f production.yml config

# Save it for review and audit
docker compose -f base.yml -f production.yml config > resolved-production.yml

# Check for common issues
docker compose -f base.yml -f production.yml config | grep -E "restart|healthcheck|logging"

Pipe the output to a file and commit it (or store it as an artifact) so that deployment pipelines can diff changes and security teams can audit the effective configuration without needing to mentally resolve extends chains.

6. Pin Versions in Production Overrides, Not in Bases

Base files should use variables for image tags, allowing each environment to pin its own version. Production overrides should specify exact tags (not latest) to ensure reproducible deployments. Staging might track a release candidate, while development uses latest.

# base.yml
services:
  app-base:
    image: myorg/app:${APP_TAG}   # Variable, not hardcoded

# production.yml
services:
  web:
    extends:
      file: base.yml
      service: app-base
# Deploy with: export APP_TAG=v2.14.3

# staging.yml  
services:
  web:
    extends:
      file: base.yml
      service: app-base
# Deploy with: export APP_TAG=v2.15.0-rc1

Common Pitfalls and Solutions

Pitfall 1: Accidentally Replacing Lists

When you override volumes, ports, expose, or dns in an extending service, you replace the entire list from the parent. This is the most common source of bugs. If your base service mounts a critical volume (like a socket or config directory) and your override specifies a different volume, the base volume is lost.

# base.yml — provides essential volume
services:
  base:
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - config:/etc/app:ro

# ❌ Wrong: override replaces everything, docker.sock mount is lost
services:
  app:
    extends:
      service: base
    volumes:
      - custom-data:/data

# ✅ Correct: re-include base volumes explicitly
services:
  app:
    extends:
      service: base
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - config:/etc/app:ro
      - custom-data:/data

Solution: Always check the resolved configuration with docker compose config and explicitly re-declare inherited list items when adding new ones. Consider using bind mounts for per-service additions while keeping shared volumes in the base.

Pitfall 2: Circular or Deeply Nested Extends

Docker Compose supports only a single level of extends — you cannot extend a service that itself extends another service. This prevents complex inheritance chains that are hard to debug.

# ❌ This will fail — chained extends are not supported
# file-a.yml
services:
  grandparent:
    image: alpine

# file-b.yml  
services:
  parent:
    extends:
      file: file-a.yml
      service: grandparent

# compose.yml
services:
  child:
    extends:
      file: file-b.yml
      service: parent    # ERROR: parent already extends grandparent

Solution: Flatten your inheritance. Create a single, comprehensive base that multiple services extend directly. If you need layered configuration, use multiple compose files with the -f flag instead of nested extends.

Pitfall 3: Extending Across Incompatible Networks

When extending a service from an external file, the networks referenced in the base must exist (or be declared) in the final compose file. If the base references a network that isn't declared in the production file, deployment fails.

# base.yml
services:
  base:
    networks:
      - service-net

# production.yml
services:
  app:
    extends:
      file: base.yml
      service: base
# ❌ Error: network "service-net" not declared in production.yml

Solution: Either declare all required networks in every environment file, or keep network definitions alongside the base services that use them, and always include the base file in your -f chain.

# ✅ Better: include base file in deployment
docker compose -f base.yml -f production.yml up -d
# base.yml provides both the service template AND the network declaration

Pitfall 4: Environment Variable Leakage

Environment variables from the base service are merged with those from the extending service. If the base sets FOO=bar and the override doesn't mention FOO, the value bar persists. This is usually desired, but can cause surprises if the base contains environment-specific defaults that shouldn't leak into production.

Solution: Keep environment variables in base files strictly limited to structural, non-secret configuration (like OpenTelemetry endpoint format, log format settings, or runtime modes that are truly universal). All secrets and environment-specific values should be set exclusively in override files.

Extends vs. Multiple Compose Files (-f Flag)

It's important to understand the distinction between extends (service-level inheritance) and using multiple compose files via the -f flag (file-level merging). They serve different purposes and are often used together in production.

# Example combining both approaches

# base.yml — service templates + shared networks
services:
  app-template:
    image: ${IMAGE}
    restart: unless-stopped
    healthcheck: ...
    networks: [app-net]

networks:
  app-net:
    driver: overlay

# production.yml — environment-specific service definitions
services:
  web:
    extends:
      file: base.yml
      service: app-template
    deploy:
      replicas: 6

# Deploy command uses BOTH mechanisms
docker compose \
  -f base.yml \
  -f production.yml \
  -f production-secrets.yml \
  up -d

In this setup, extends handles the DRY inheritance between web and potentially other services (worker, scheduler, etc.), while the -f chain composes the base infrastructure, production overrides, and secrets into a single deployment. The two features are complementary, not competing.

Conclusion

The extends directive transforms Docker Compose from a simple service orchestrator into a powerful configuration management tool suitable for production environments. By centralizing shared service definitions in base files and layering environment-specific overrides on top, you eliminate duplication, enforce consistency across dozens or hundreds of services, and dramatically reduce the risk of configuration drift between environments.

In production, the stakes are high — a missing health check, a mismatched logging driver, or an inconsistent restart policy can cause outages that are difficult to diagnose. extends provides a clean, auditable inheritance model that makes these configurations mandatory and visible. When combined with the docker compose config validation command, variable defaults, and a disciplined separation of base and override files, it becomes an indispensable part of any production-grade Docker deployment workflow.

Start by extracting the common DNA of your services into a dedicated base file. Use variables for anything environment-specific. Validate the merged output before every deployment. Keep your inheritance flat and your templates well-documented. With these practices,

🚀 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