StackPractices
intermediate By Mathias Paulenko

Override Docker Compose Configs per Environment

How to use Docker Compose override files for environment-specific configurations, covering dev, test, staging, production, profiles, and secrets.

Topics: devops

Overview

Docker Compose merges two or more files into a single config. Everything starts from the base docker-compose.yml, which holds the service structure. Override files customize it for each environment: different ports or volumes in dev, resource limits in prod, maybe a Mailhog service that only exists locally. Compose merges files in the order you pass them — later files override or extend earlier ones — so one base config covers dev, test, staging, and production without copy-pasting the whole thing four times.

Mermaid flowchart LR diagram

If what you need is two completely separate Compose stacks for dev and prod rather than one base plus deltas, the Docker Compose dev/prod split recipe covers that approach — override files work best when environments share most of the config and differ in details.

When to Use

  • Local development with hot reload and debug tools
  • Running tests in isolation with test-specific configs
  • Staging/production with production-like settings (resource limits, no debug)
  • When you need different services per environment (e.g., Mailhog in dev, SES in prod)
  • Managing multi-service applications across environments

When NOT to Use

  • Single environment — a single docker-compose.yml is enough
  • Production deployments — use Kubernetes or ECS, not Docker Compose
  • When configs differ drastically — separate files are clearer than overrides
  • When you need complex orchestration — Compose is for dev/test, not production

Solution

Base compose file

# docker-compose.yml — Base configuration
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - LOG_LEVEL=info
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: myapp
      POSTGRES_PASSWORD: ${DB_PASSWORD:-changeme}
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U myapp"]
      interval: 5s
      timeout: 3s
      retries: 5
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    restart: unless-stopped

volumes:
  db-data:

Development override

# docker-compose.override.yml — Used by default with `docker compose up`
services:
  app:
    build:
      target: dev  # Multi-stage build dev target
    ports:
      - "3000:3000"
      - "9229:9229"  # Node.js debugger
    environment:
      - NODE_ENV=development
      - LOG_LEVEL=debug
    volumes:
      - .:/app           # Hot reload
      - /app/node_modules  # Prevent host overwrite
    command: npm run dev  # Override entrypoint

  db:
    ports:
      - "5432:5432"  # Expose for local tools
    environment:
      POSTGRES_PASSWORD: devpassword

  # Dev-only services
  mailhog:
    image: mailhog/mailhog:latest
    ports:
      - "1025:1025"  # SMTP
      - "8025:8025"  # Web UI

  adminer:
    image: adminer:latest
    ports:
      - "8080:8080"
    depends_on:
      - db

Production override

# docker-compose.prod.yml
services:
  app:
    build:
      target: production
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - LOG_LEVEL=warn
      - DATABASE_URL=postgres://myapp:${DB_PASSWORD}@db:5432/myapp
    deploy:
      replicas: 3
      resources:
        limits:
          cpus: "1.0"
          memory: 512M
        reservations:
          cpus: "0.5"
          memory: 256M
      restart_policy:
        condition: on-failure
        max_attempts: 3
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

  db:
    environment:
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - db-data:/var/lib/postgresql/data
    deploy:
      resources:
        limits:
          cpus: "2.0"
          memory: 2G

  redis:
    deploy:
      resources:
        limits:
          cpus: "0.5"
          memory: 256M

Test override

# docker-compose.test.yml
services:
  app:
    build:
      target: test
    environment:
      - NODE_ENV=test
      - DATABASE_URL=postgres://myapp:testpass@db:5432/testdb
    command: npm test
    depends_on:
      db:
        condition: service_healthy

  db:
    environment:
      POSTGRES_DB: testdb
      POSTGRES_PASSWORD: testpass
    # Drop the persistent volume — tests get an ephemeral database
    volumes: !reset []

  # redis doesn't exist in the test environment
  redis: !reset null

Running with different overrides

# Development (default — uses docker-compose.yml + docker-compose.override.yml)
docker compose up

# Production
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

# Test
docker compose -f docker-compose.yml -f docker-compose.test.yml up --abort-on-container-exit

# Build production images
docker compose -f docker-compose.yml -f docker-compose.prod.yml build

# Run specific service
docker compose -f docker-compose.yml -f docker-compose.prod.yml up app

# View merged config
docker compose -f docker-compose.yml -f docker-compose.prod.yml config

Using COMPOSE_FILE environment variable

# .env
COMPOSE_FILE=docker-compose.yml:docker-compose.prod.yml

# Now `docker compose up` uses both files
docker compose up -d

Compose profiles

# docker-compose.yml
services:
  app:
    # Always started
    image: my-app

  db:
    # Always started
    image: postgres:16

  debug-tools:
    # Only started with --profile debug
    profiles:
      - debug
    image: nicolaka/netshoot
    network_mode: "service:app"

  load-test:
    # Only started with --profile loadtest
    profiles:
      - loadtest
    image: grafana/k6
    volumes:
      - ./tests:/scripts
# Start without profiles
docker compose up -d  # Only app and db

# Start with debug profile
docker compose --profile debug up -d

# Start with load test profile
docker compose --profile loadtest up

# Start with multiple profiles
docker compose --profile debug --profile loadtest up

Secrets management

# docker-compose.prod.yml
services:
  app:
    secrets:
      - db-password
      - api-key
    environment:
      - DB_PASSWORD_FILE=/run/secrets/db-password
      - API_KEY_FILE=/run/secrets/api-key

  db:
    secrets:
      - db-password
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db-password

secrets:
  db-password:
    file: ./secrets/db-password.txt
  api-key:
    file: ./secrets/api-key.txt

Environment variables from .env

# .env
DB_PASSWORD=supersecret
REDIS_URL=redis://redis:6379
JWT_SECRET=my-jwt-secret
# docker-compose.yml
services:
  app:
    environment:
      - DB_PASSWORD=${DB_PASSWORD}
      - REDIS_URL=${REDIS_URL}
      - JWT_SECRET=${JWT_SECRET}

Per-environment .env files

# .env.dev
DB_PASSWORD=devpassword
LOG_LEVEL=debug

# .env.prod
DB_PASSWORD=supersecret
LOG_LEVEL=warn
# Pick the env file to load — pairs naturally with -f overrides
docker compose --env-file .env.dev up
docker compose --env-file .env.prod -f docker-compose.yml -f docker-compose.prod.yml up -d
# Or pin an env file per service — the file is part of the config,
# so an override can point it somewhere else per environment
services:
  app:
    env_file:
      - .env.dev

Multi-file merge behavior

# docker-compose.yml (base)
services:
  app:
    image: my-app:latest
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - LOG_LEVEL=info
# docker-compose.override.yml
services:
  app:
    # Ports are merged (not replaced)
    ports:
      - "9229:9229"  # Added to 3000:3000
    # Environment is merged
    environment:
      - NODE_ENV=development  # Overrides production
      - DEBUG=true             # Added

How the Merge Works

Compose merges files left to right, following different rules per attribute type:

Attribute typeMerge behaviorExample
Sequences (ports, volumes, secrets)Concatenated — override adds, doesn’t replaceBase 3000:3000 + override 9229:9229 → both
Maps (environment, labels, sysctls)Merged by key — later value wins for the same keyNODE_ENV replaced, DEBUG added
Scalars (image, command, restart)Replaced entirely by the latest filecommand: npm run dev wins
depends_on, networksMerged by service/network nameOverride adds conditions

Two escape hatches from the Compose spec change these rules:

  • !reset <value> — removes the inherited value entirely. volumes: !reset [] drops all base volumes; redis: !reset null removes the service from the merged config (this is how the test override above drops redis, cleaner than a fake profile).
  • !override — replaces an attribute wholesale instead of merging, useful for ports when you don’t want the base mappings to leak into prod.

Three details that bite people:

  1. All relative paths resolve against the base file’s directory, not the override’s. If your override lives in another folder, keep paths relative to the base or use absolute paths.
  2. Watch your file names if you rely on auto-loading. Compose v2 finds compose.yaml, compose.yml, docker-compose.yaml, or docker-compose.yml on its own, plus the .override file that matches whichever it found. The docker-compose.* names still work everywhere; compose.* is the newer convention. Anything else only loads through explicit -f flags.
  3. Order is the override order. -f a.yml -f b.yml means b wins on conflicts, which is why people usually stack base → environment → a local untracked file — personal tweaks land last every time.

Verify what Compose actually merged before debugging anything else:

docker compose -f docker-compose.yml -f docker-compose.prod.yml config

That prints the resolved config — the single source of truth for “why is this port/env var/volume here”.

Variants

Using extends to reuse service definitions

# docker-compose.yml
services:
  web:
    extends:
      file: docker-compose.base.yml
      service: app
    environment:
      - APP_ROLE=web

  worker:
    extends:
      file: docker-compose.base.yml
      service: app
    environment:
      - APP_ROLE=worker
    ports: []  # No ports for worker

Docker Compose with healthcheck dependencies

services:
  app:
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started

  db:
    image: postgres:16
    healthcheck:
      test: ["CMD-SHELL", "pg_isready"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 10s

Watch mode for development

# docker-compose.override.yml
services:
  app:
    develop:
      watch:
        - action: sync
          path: ./src
          target: /app/src
        - action: rebuild
          path: package.json
docker compose watch  # Auto-sync changes and rebuild

Best Practices

  • Keep docker-compose.yml as the base — don’t put environment-specific config in it
  • Use docker-compose.override.yml for dev — Compose picks it up automatically
  • Reach for explicit -f flags on every non-dev environment — prevents accidental dev overrides
  • Store secrets in .env or Docker secrets, never hardcoded in YAML
  • Mark optional services with profiles so docker compose up doesn’t start the world
  • Add depends_on with condition plus healthchecks — the app connects only when the DB is actually ready
  • Run docker compose config to verify merged output — catches merge issues before they reach a running stack

Common Mistakes

  • Forgetting override.yml is automatic: docker compose up merges docker-compose.override.yml whenever it exists — reach for -f flags when you want control over what loads.
  • Hardcoding secrets in YAML: passwords written into docker-compose.yml end up in git. Keep them in .env or Docker secrets.
  • Not using healthchecks: the app boots before the DB is ready and connection errors follow. Put healthchecks on anything your services wait for.
  • Using Compose for production: Compose is designed for dev/test — real production orchestration means Kubernetes, ECS, or Swarm.
  • Not cleaning up volumes: docker compose down keeps volumes around. If you want the data gone, that’s docker compose down -v.

Further Reading

Frequently Asked Questions

How does Docker Compose merge files?

It deep-merges: lists (ports, volumes) are concatenated, maps (environment, labels) are merged with later values overriding earlier ones, and scalars (image, command) are replaced.

What is the default override file?

docker-compose.override.yml. If it exists in the same directory, Compose merges it automatically with docker-compose.yml when you run docker compose up.

Can I use multiple override files?

Yes. Chain -f flags — later files win on conflicts: docker compose -f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.local.yml up. A typical order is base → environment → untracked local file.

What is the difference between compose.yaml and docker-compose.yml?

Compose v2 searches for compose.yaml first, then compose.yml, docker-compose.yaml, and finally docker-compose.yml — plus the matching .override file for whichever it finds. All four names work; compose.yaml is the newer convention. Files with any other name only load via explicit -f flags.

How do I remove a key or service that the base file defines?

Use !reset in the override: volumes: !reset [] clears an inherited list, and service_name: !reset null drops a whole service from the merged config. For sequence fields you want to replace rather than append to, !override swaps the whole attribute instead of merging.

How do I use a different .env file per environment?

Pass --env-file .env.prod on the command line, or set env_file: inside the service so an override file can repoint it. Without either, Compose reads .env in the working directory — one file for every environment.

What are Compose profiles?

Profiles mark services as optional: a service with a profiles list only starts when you pass --profile <name>. Your debug and test tools can live in the same file without booting every time you run the stack.

How do I pass environment variables?

Drop a .env file next to your compose file — Compose picks it up on its own. Then reference the variables with ${VAR_NAME} inside the YAML.