docker-compose.override.yml is a magic file that lets you tweak your local development environment without touching your main docker-compose.yml.
Let’s see it in action. Imagine you have a docker-compose.yml that defines your web app and a database:
# docker-compose.yml
version: '3.8'
services:
webapp:
build: .
ports:
- "8000:8000"
volumes:
- .:/app
command: python manage.py runserver 0.0.0.0:8000
db:
image: postgres:14
environment:
POSTGRES_DB: myappdb
POSTGRES_USER: myappuser
POSTGRES_PASSWORD: myapppassword
Now, you want to make a quick change for local debugging: maybe you want to attach a debugger to your web app, or use a different database image. This is where docker-compose.override.yml shines.
Create a file named docker-compose.override.yml in the same directory:
# docker-compose.override.yml
version: '3.8'
services:
webapp:
ports:
- "5678:5678" # Port for debugger
volumes:
- ./src:/app/src # Mount only the src directory for faster reloads
command: python -m debugpy --listen 0.0.0.0:5678 --wait-for-client manage.py runserver 0.0.0.0:8000
db:
image: postgres:15 # Use a newer postgres version locally
ports:
- "5432:5432" # Expose DB port for direct access
When you run docker-compose up, Docker Compose automatically merges docker-compose.yml and docker-compose.override.yml. It uses the docker-compose.yml as the base and applies the overrides from docker-compose.override.yml.
Notice how webapp in the override file adds a new port (5678:5678), changes the volume mount to be more specific (./src:/app/src), and modifies the command to include debugpy. The db service gets a new image (postgres:15) and its port is exposed (5432:5432).
The beauty is that your main docker-compose.yml remains clean and production-ready. Your collaborators don’t need to see your local debugging setup. You can commit your docker-compose.yml and keep docker-compose.override.yml in your .gitignore.
This strategy solves the problem of environment-specific configurations. Instead of scattering if local: checks in your application code or creating complex shell scripts to manage different configurations, you declaratively define your local development tweaks in a separate, easy-to-manage file. It’s about isolating the ephemeral from the permanent.
The mental model is that docker-compose.override.yml isn’t a separate configuration file; it’s a delta, a patch, or a set of instructions to modify the base configuration. When Docker Compose reads both files, it performs a deep merge. For dictionaries (like ports, environment, volumes), it merges the keys. For lists (like command, entrypoint), it concatenates them. For simple values (like image), it replaces them.
For example, if your docker-compose.yml had ports: - "8000:8000" and your docker-compose.override.yml had ports: - "5678:5678", the resulting merged configuration for webapp would have both ports exposed: ports: - "8000:8000" - "5678:5678". If the override had command: ["echo", "hello"] and the base had command: ["echo", "world"], the merged command would be ["echo", "hello", "echo", "world"]. This list concatenation behavior is crucial to understand for commands and entrypoints.
One subtle, yet powerful, aspect is how docker-compose.override.yml can completely redefine a service if you’re not careful. If you specify a service in the override file, it’s treated as a full definition for that service, and any properties not specified in the override are effectively dropped from the merged configuration. This is why you often see the version key repeated in override files, even though it’s not strictly necessary for the merge itself; it ensures the override file is syntactically valid as a standalone Compose file.
The next step is understanding how to manage multiple override files for different scenarios, like using a specific database version for integration tests.