Docker Compose Overrides Are the Env Layer You're Already Missing
Most teams fork their docker-compose.yml per environment and spend the next year keeping three files in sync. There's a better way.
Most teams I talk to have three versions of docker-compose.yml — one for dev, one for staging, one for prod — and they're all 80% identical. Someone changed the base image six months ago, updated dev and prod but forgot staging, and now staging has been silently running a different PHP version for half a year. I've seen this exact situation cause a production incident at a print management company I work with. The fix isn't discipline. The fix is structure.
Docker Compose has had override file support for years and it's genuinely underused. You write one canonical base file, then layer environment-specific overrides on top. Compose merges them at runtime. You never fork the base.
What the Override System Actually Does
When you run docker compose up, Compose automatically merges docker-compose.yml with docker-compose.override.yml if it exists. That's the default behavior — no flags required. For non-default override files, you stack them with -f:
docker compose -f docker-compose.yml -f docker-compose.staging.yml up -d
Merge semantics matter here. Scalars (strings, numbers) get replaced. Mappings (like environment) get merged — keys in the override win over the base, keys not mentioned in the override are kept from the base. Arrays like ports and volumes get concatenated, not replaced. That last one will bite you if you're not careful, and I'll come back to it.
The mental model: the base file is the contract. Every environment runs the same services, the same image, the same health checks. The override file is the delta — what's different about this environment, and only that.
The Base File
Here's a stripped-down base for a Laravel app. I'm leaving out things that genuinely vary by environment:
# docker-compose.yml
services:
app:
image: ghcr.io/nwos/myapp:${APP_VERSION:-latest}
restart: unless-stopped
environment:
APP_ENV: ${APP_ENV}
APP_KEY: ${APP_KEY}
DB_HOST: db
DB_DATABASE: ${DB_DATABASE}
DB_USERNAME: ${DB_USERNAME}
DB_PASSWORD: ${DB_PASSWORD}
REDIS_HOST: redis
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
networks:
- internal
worker:
image: ghcr.io/nwos/myapp:${APP_VERSION:-latest}
restart: unless-stopped
command: php artisan queue:work --sleep=3 --tries=3
environment:
APP_ENV: ${APP_ENV}
APP_KEY: ${APP_KEY}
DB_HOST: db
DB_DATABASE: ${DB_DATABASE}
DB_USERNAME: ${DB_USERNAME}
DB_PASSWORD: ${DB_PASSWORD}
REDIS_HOST: redis
depends_on:
- app
networks:
- internal
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${DB_DATABASE}
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- internal
redis:
image: redis:7-alpine
restart: unless-stopped
networks:
- internal
volumes:
db_data:
networks:
internal:
No ports exposed. No bind mounts. No build: context. No dev tooling. Just the shape of the application.
The Dev Override
Dev is where you want all the conveniences: hot reload via bind mount, Xdebug, exposed ports so you can hit localhost directly, Mailpit for catching outbound email:
# docker-compose.override.yml (picked up automatically)
services:
app:
build:
context: .
dockerfile: Dockerfile.dev
volumes:
- .:/var/www/html
ports:
- "8080:80"
environment:
APP_DEBUG: "true"
XDEBUG_MODE: develop,debug
XDEBUG_CONFIG: client_host=host-docker-internal
MAIL_HOST: mailpit
MAIL_PORT: 1025
worker:
build:
context: .
dockerfile: Dockerfile.dev
volumes:
- .:/var/www/html
db:
ports:
- "5432:5432"
mailpit:
image: axllent/mailpit
ports:
- "8025:8025"
- "1025:1025"
networks:
- internal
Because this file is named docker-compose.override.yml, running docker compose up in dev just works. No flags. The build context gets layered in, Xdebug gets configured, ports get opened, Mailpit appears.
The Staging Override
Staging should be as close to prod as possible. No bind mounts, no debug flags, but I do want ports reachable so our internal test suite can hit it, and I want the staging-specific image tag:
# docker-compose.staging.yml
services:
app:
ports:
- "8080:80"
environment:
APP_DEBUG: "false"
APP_URL: https://staging.myapp.nwos.com
worker:
environment:
APP_DEBUG: "false"
Deploy it:
APP_VERSION=1.4.2 docker compose -f docker-compose.yml -f docker-compose.staging.yml up -d
Environment values come from a .env.staging file we load before running — or from CI secrets piped into the environment. The compose files themselves never contain credentials.
The Prod Override
Prod gets resource limits, no exposed ports (Nginx/Traefik handles that at the host level), multiple worker replicas, and a read-only filesystem where it matters:
# docker-compose.prod.yml
services:
app:
environment:
APP_DEBUG: "false"
APP_URL: https://myapp.com
LOG_CHANNEL: stderr
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
worker:
environment:
APP_DEBUG: "false"
LOG_CHANNEL: stderr
deploy:
replicas: 2
resources:
limits:
cpus: "0.5"
memory: 256M
Deploy:
APP_VERSION=1.4.2 docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
Same image tag that went through staging. Same service definitions. Only the operational parameters change.
The Gotchas That Will Get You
Array concatenation. I mentioned this earlier. ports, volumes, env_file, depends_on in list form — these are concatenated, not replaced. If your base file had a volume and your override adds another, you get both. Usually that's fine. Where it burns you: if you accidentally define ports in the base (easy to do while testing), and then your override adds more ports, prod ends up with both. Keep the base file clean and you won't hit this.
env_file stacking is confusing. I avoid env_file in the compose files entirely now. I load the right .env before invoking compose, so the variables are just in the shell environment. Cleaner, more explicit, easier to reason about in CI.
The build: key in prod. If you include a build: key anywhere that a CI/CD pipeline runs, someone will accidentally trigger a local build instead of pulling the pre-built image. The base file should never have build:. Dev override gets it. Prod and staging should never touch it.
docker-compose.override.yml is automatic. If you're on a machine where someone left this file and you run docker compose up expecting the bare base, you get the merged result. Not a disaster usually, but surprising. I document this in the repo README and add a note in the CI scripts that explicitly do -f docker-compose.yml -f docker-compose.prod.yml — the explicit flags bypass the automatic override pickup entirely.
depends_on merging. If you add a service in the override (like Mailpit in dev) and another service depends on it, you have to define the dependency in the override too. The base can't reference a service that doesn't exist in the base. This is correct behavior but it means dev-only services need their dependency wiring in the dev override, not the base.
When I'd Reach for This
Anytime I have more than one environment running Compose. Which is almost every project I ship. The overhead of writing the override files is maybe 20 minutes. The payoff is that when I update the base image or change a health check or add a new service, I do it in one place and all environments get it on next deploy.
I'd also reach for this when handing off a project to a client who will run their own instance. Ship them the base file and a prod override template. They fill in the secrets. They're not staring at 200 lines of compose config they didn't write and don't understand.
When I wouldn't bother: single-environment projects, or anything headed toward Kubernetes soon. If you're three months from moving to k8s, don't build elaborate Compose layering — you'll throw it away. Write flat, simple compose files and spend the time on your Helm charts instead.
I also wouldn't lean on this for secrets management. Overrides are great for structural config — which ports to expose, whether to mount source code, how many replicas to run. Secrets should come from environment variables loaded by your deployment tooling (Vault, AWS Secrets Manager, GitHub Actions secrets), not from files in the repo. The compose files should reference ${DB_PASSWORD} and never define it.
Closing
This pattern costs almost nothing to set up and pays off every single time you touch configuration for any reason. One base file, three overrides, zero forks. The print management client I mentioned earlier migrated to this pattern in an afternoon and hasn't had a version drift incident since. That's the bar I use for good infrastructure tooling: it should make the accidental failure mode structurally impossible, not just unlikely.
Need help shipping something like this? Get in touch.