A Docker Compose override file lets you keep one base service definition and layer environment-specific settings on top of it, so development and production stop drifting apart. In this guide you build a small web plus database stack, split it into a base file, a dev override and a production file, and merge them with -f and COMPOSE_FILE.
Before you start
You need:
- A Linux VPS running Ubuntu 22.04/24.04, Debian 12 or AlmaLinux/Rocky 8–9.
- Docker Engine and the Compose v2 plugin. Check with
docker compose version. - SSH access as a user that can run Docker (a member of the
dockergroup, or usesudo). - Comfort reading YAML. Indentation matters, and tabs are not allowed.
If Docker is not installed yet, follow Install Docker and Docker Compose on a Linux VPS first.
Create a project directory and move into it:
mkdir -p /home/deploy/app && cd /home/deploy/appCompose reads compose.yaml, compose.yml, then docker-compose.yml, in that order of preference. This guide uses docker-compose.yml because the override naming convention builds on it.
Create the base Compose file
The base file holds everything that is true in every environment: images, service names, restart policy, volumes.
nano docker-compose.ymlPaste:
services:
web:
image: nginx:alpine
restart: unless-stopped
ports:
- "80:80"
environment:
APP_ENV: production
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_PASSWORD: change-me
volumes:
- db_data:/var/lib/postgresql/data
volumes:
db_data:Save with Ctrl+O, Enter, then Ctrl+X. If nano is new to you, Edit files with nano covers the keys.
Note what is deliberately missing: no bind mounts, no debug flags, no extra published ports. Those belong in the environment layers.
Add the development Docker Compose override file
Compose automatically loads a file named docker-compose.override.yml next to the base file when you run docker compose up with no -f flags. That makes it the natural place for developer-only settings.
nano docker-compose.override.ymlservices:
web:
ports:
- "8080:80"
volumes:
- ./site:/usr/share/nginx/html:ro
environment:
APP_ENV: developmentCreate the content directory so the bind mount has something to serve:
mkdir -p site && echo '<h1>dev build</h1>' > site/index.htmlNow docker compose up -d uses the base file plus this override, and the site is reachable on port 8080 while the database keeps its named volume.
Tip: Keep docker-compose.override.yml out of your production deploy. It is meant to be convenient on a workstation, not on a server.
Add a production override file
Production settings go in a separate file that you apply on purpose. Create docker-compose.prod.yml:
nano docker-compose.prod.ymlservices:
web:
ports:
- "80:80"
environment:
APP_ENV: production
deploy:
resources:
limits:
memory: 512M
db:
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}The ${POSTGRES_PASSWORD} reference is filled from the environment or a .env file in the same directory. Create it with restrictive permissions:
printf 'POSTGRES_PASSWORD=%s\n' "$(openssl rand -hex 16)" > .env
chmod 600 .envWarning: Never commit .env to version control, and never hard-code a real database password in a Compose file that lives in a repository.
How Docker Compose override files merge
When you pass more than one file, Compose merges them left to right. The rules are simple but they surprise people:
- Mappings such as
environment,labelsanddeployare merged key by key. A key set in a later file wins. - Single values such as
image,restartandcommandare replaced outright by the last file that sets them. - Lists such as
ports,volumesanddnsare appended, not replaced. - Relative paths in
volumesandbuildare resolved against the directory of the first file, not the file that declares them.
That last point about lists is why the dev and production port blocks should not both be active at once: merging 8080:80 with 80:80 publishes both ports. Keep environments in separate files and select exactly one.
Run Compose with explicit -f files
For production, list the files in order. The base file always comes first.
docker compose -f docker-compose.yml -f docker-compose.prod.yml configconfig prints the merged result and exits without starting anything. Read it before you deploy.
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -dIf you deploy often, set the file list once instead of repeating flags. COMPOSE_FILE uses a colon separator on Linux:
export COMPOSE_FILE=docker-compose.yml:docker-compose.prod.yml
docker compose config
docker compose up -dAdd that export to your shell profile or a small deploy script so every command in the session targets production. To go back to the dev defaults, run unset COMPOSE_FILE.
Useful variations:
docker compose config --services
docker compose -f docker-compose.yml -f docker-compose.prod.yml ps
docker compose -f docker-compose.yml -f docker-compose.prod.yml logs -f webWarning: docker compose down -v deletes named volumes, including the database volume. Use plain down unless you intend to destroy data.
Check it works
Confirm the merged configuration, then the running state:
docker compose config | grep -A4 'ports:'
docker compose ps
curl -I http://203.0.113.10With the production file applied, the web container should listen on port 80 and curl should return HTTP/1.1 200 OK. With no -f flags, the same command on port 8080 should serve your dev index.html.
Check the environment variable made it through:
docker compose exec web printenv APP_ENVExpected output is production with the prod file and development with the override. If you changed a Compose file, recreate the containers so the new settings apply:
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --force-recreateTroubleshooting
Error response from daemon: port is already allocated— the dev override is still being loaded, so both port 80 and 8080 are requested. Pass-fexplicitly or setCOMPOSE_FILE, and stop the old stack withdocker compose down.- An environment variable keeps the old value —
environmentin list form (- KEY=value) is appended rather than merged, so duplicates confuse the result. Switch both files to map form and let the last file win. configoutput is too long to read — narrow it withdocker compose config --servicesfor service names, ordocker compose config | lessto scroll the full merge.- The web container returns 403 or 404 after a bind mount — the host path does not exist or is empty, so Compose creates an empty directory. Check the path with
ls -la./siteand confirm the files are there. - Settings changed but nothing happened — running containers keep their original configuration. Recreate them with
up -d --force-recreaterather than restarting.
Next steps
- Install Docker and Docker Compose on a Linux VPS if you are setting up a second server.
- Docker volumes vs bind mounts: store container data safely to decide where the database and uploads should live.
- Docker restart policies: keep containers running after a reboot so the stack survives a VPS reboot.
- Troubleshooting container networking issues: bridge vs host mode in Docker when published ports do not behave as expected.
