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:
docker-compose.yml— the base configurationdocker-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/16This 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 spaceThe 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/24Compose 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.1Always 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.1Clean — 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.1This 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 -dThis pattern gives you:
- Traefik integration — automatic TLS certificates and routing, only in production
- Resource limits — prevent containers from consuming all available memory
- Restart policies —
alwaysfor 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/excludeThis 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
| Scenario | File | Auto-loaded? | In git? |
|---|---|---|---|
| Local network/port fixes | docker-compose.override.yml | Yes | No |
| Development tools (debugger, hot reload) | docker-compose.override.yml | Yes | No |
| Production (Traefik, TLS, limits) | docker-compose.prod.yml | No (-f flag) | Yes |
| CI/CD testing | docker-compose.ci.yml | No (-f flag) | Yes |
| Monitoring (Prometheus, Grafana) | docker-compose.monitoring.yml | No (-f flag) | Yes |
Key takeaways
docker-compose.override.ymlis auto-loaded — just create it and it works. No flags needed.- Use
!overridefor arrays — without it, IPAM configs, port lists, and volume arrays get merged instead of replaced, causing conflicts. - Always verify with
docker compose config— check the merged result before starting your stack. - Development overrides stay local — exclude them from git via
.git/info/exclude. - Production overrides get reviewed — name them explicitly (
.prod.yml) and load with-fflags. - 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.
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.