Docker Compose Override: Keep Your Config Clean Across Environments

Every Docker Compose project eventually hits the same problem: your local setup needs different settings than what’s in the repository. Custom network subnets, debug ports, Traefik labels for your reverse proxy, volume mounts for live reloading. You could edit docker-compose.yml directly — but then you’re fighting git diff for the rest of the project.

Docker Compose has a built-in solution: the override file. It’s simple, powerful, and surprisingly poorly documented for some of its most useful features.

How docker-compose.override.yml works

When you run docker compose up, Compose automatically loads two files in order:

  1. docker-compose.yml — the base configuration
  2. docker-compose.override.yml — your local overrides (if it exists)

No flags needed. No environment variables. Just create the file and Compose picks it up. The override is deep-merged on top of the base: scalar values are replaced, objects are merged recursively, and arrays are… well, that’s where it gets interesting.

Real-world example: fixing network conflicts in OpenKAT

OpenKAT is an open-source security scanning platform that runs 13+ containers across two Docker networks. The docker-compose.yml defines fixed subnets:

networks:
  default:
    ipam:
      config:
        - subnet: 172.30.0.0/16
  boefjes:
    ipam:
      config:
        - subnet: 172.31.0.0/16

This works on most machines. But on ours, 172.31.0.0/24 is the office DNS subnet, and OrbStack (our Docker runtime on macOS) reserves the entire 172.16.0.0/12 range internally. Every docker compose up fails with:

failed to create network: Pool overlaps with other one on this address space

The fix: a docker-compose.override.yml that moves both networks to a non-conflicting range.

The !override tag — replacing arrays instead of merging

Here’s the catch. If you write a naive override:

# This does NOT work as expected
networks:
  boefjes:
    ipam:
      config:
        - subnet: 10.100.0.0/24

Compose merges the array entries from both files, producing duplicate or conflicting subnets. The result is a mess that Docker refuses to create.

The solution is the !override YAML tag, a Docker Compose v2 feature that tells Compose to replace the array entirely:

networks:
  default:
    ipam:
      config: !override
        - subnet: fc42:ca7::/64
        - subnet: 10.98.0.0/24
          gateway: 10.98.0.1
  boefjes:
    ipam:
      config: !override
        - subnet: fc42:ca7:1::/64
        - subnet: 10.100.0.0/24
          gateway: 10.100.0.1

Always verify the effective configuration after creating an override:

$ docker compose config | grep -A6 'boefjes:'
  boefjes:
    ipam:
      config:
        - subnet: fc42:ca7:1::/64
        - subnet: 10.100.0.0/24
          gateway: 10.100.0.1

Clean — no duplicates, no conflicts.

Development overrides: debug ports, volumes, and more

Network fixes are just the start. A development override typically adds things the base config intentionally leaves out:

# docker-compose.override.yml — development
services:
  rocky:
    volumes:
      - ./rocky:/app/rocky
    environment:
      - DEBUG=True
      - DJANGO_DEBUG_TOOLBAR=True
    ports:
      - "8000:8000"
      - "5678:5678"  # debugpy

  octopoes_api:
    ports:
      - "8001:80"    # expose API for local testing

  scheduler:
    ports:
      - "8004:8000"  # expose scheduler API

networks:
  boefjes:
    ipam:
      config: !override
        - subnet: fc42:ca7:1::/64
        - subnet: 10.100.0.0/24
          gateway: 10.100.0.1

This adds live code reloading, a debug port, and exposed APIs — without touching the tracked compose file.

Production overrides: Traefik, resource limits, and TLS

For production, you typically want a tracked override file that goes through code review. The convention is to name it explicitly:

# docker-compose.prod.yml — production override
services:
  rocky:
    restart: always
    deploy:
      resources:
        limits:
          memory: 1G
          cpus: '1.0'
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.rocky.rule=Host(`openkat.example.com`)"
      - "traefik.http.routers.rocky.entrypoints=websecure"
      - "traefik.http.routers.rocky.tls=true"
      - "traefik.http.routers.rocky.tls.certresolver=letsencrypt"
      - "traefik.http.services.rocky.loadbalancer.server.port=8000"
    environment:
      - DJANGO_ALLOWED_HOSTS=openkat.example.com
      - DJANGO_CSRF_TRUSTED_ORIGINS=https://openkat.example.com

  octopoes_api:
    restart: always
    deploy:
      resources:
        limits:
          memory: 2G

  scheduler:
    restart: always
    labels:
      - "traefik.enable=false"

  bytes:
    restart: always
    labels:
      - "traefik.enable=false"

Unlike the development override, this file is not auto-loaded — you specify it explicitly:

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

This pattern gives you:

  • Traefik integration — automatic TLS certificates and routing, only in production
  • Resource limits — prevent containers from consuming all available memory
  • Restart policiesalways for production, not set for development (so crashes are visible)
  • No exposed debug ports — production only exposes services through Traefik

Adding Traefik itself to the stack

If Traefik isn’t already running on your host, add it to the production override:

services:
  traefik:
    image: traefik:v3.4
    command:
      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=false"
      - "--entrypoints.web.address=:80"
      - "--entrypoints.websecure.address=:443"
      - "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
      - "--certificatesresolvers.letsencrypt.acme.email=admin@example.com"
      - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - letsencrypt:/letsencrypt
    restart: always

volumes:
  letsencrypt:

With exposedbydefault=false, only services with traefik.enable=true labels get routed — everything else stays internal.

The complete file layout

project/
  docker-compose.yml              # Base config (tracked in git)
  docker-compose.override.yml     # Local dev overrides (git-excluded)
  docker-compose.prod.yml         # Production overrides (tracked, reviewed)
  .env                            # Local secrets (git-excluded)
  .env-defaults                   # Default non-secret values (tracked)

Keep the override out of version control by adding it to .git/info/exclude:

echo "docker-compose.override.yml" >> .git/info/exclude

This is better than adding it to .gitignore when the gitignore is a tracked upstream file you don’t want to modify. The .git/info/exclude file works identically but stays local to your clone.

When to use which approach

ScenarioFileAuto-loaded?In git?
Local network/port fixesdocker-compose.override.ymlYesNo
Development tools (debugger, hot reload)docker-compose.override.ymlYesNo
Production (Traefik, TLS, limits)docker-compose.prod.ymlNo (-f flag)Yes
CI/CD testingdocker-compose.ci.ymlNo (-f flag)Yes
Monitoring (Prometheus, Grafana)docker-compose.monitoring.ymlNo (-f flag)Yes

Key takeaways

  1. docker-compose.override.yml is auto-loaded — just create it and it works. No flags needed.
  2. Use !override for arrays — without it, IPAM configs, port lists, and volume arrays get merged instead of replaced, causing conflicts.
  3. Always verify with docker compose config — check the merged result before starting your stack.
  4. Development overrides stay local — exclude them from git via .git/info/exclude.
  5. Production overrides get reviewed — name them explicitly (.prod.yml) and load with -f flags.
  6. Traefik labels belong in the production override — not in the base config where they’d clutter development.

This post was inspired by a real debugging session on the OpenKAT project, where a subnet conflict between OrbStack and an internal DNS range led us down the rabbit hole of Docker Compose merge behavior.


You May Also Like These Topics...

Van Data naar Dreigingsinformatie: De Evolutie van SIEM met Elastic

In het huidige digitale landschap, waar cyberdreigingen steeds geavanceerder en talrijker worden, is robuuste beveiligingsmonitoring geen luxe meer, maar een absolute noodzaak. Jarenlang vormde Security Information and Event Management (SIEM) de hoeksteen van de cyberdefensie van veel organisaties.

Previous Post

Elasticsearch ML Jobs: Automatische Inventarisatie, Analyse en Herstel met Python

Next Post

Developing Custom Boefjes for OpenKAT: A Developer Guide

Geef een reactie

Je e-mailadres wordt niet gepubliceerd. Vereiste velden zijn gemarkeerd met *