Separate Dev and Prod With Docker Compose Override Files
Use one compose.yaml for a Django stack with compose.override.yaml for development and compose.prod.yaml for production: bind mounts and runserver locally, built images, restart policies, and no exposed database ports in production.
Problem statement
The Compose file that makes local development pleasant (source code bind-mounted, runserver with auto-reload, PostgreSQL published on port 5432, DEBUG=1) is dangerous in production, and the production file (built images, Gunicorn, restart policies, no published database port) is tedious locally. Teams end up with two diverging files or with development settings leaking into production. Compose merges multiple files, so a shared base plus small environment-specific overrides keeps one source of truth.
- Put shared service definitions in
compose.yaml(images, environment file names, networks, volumes, health checks). - Put development tweaks in
compose.override.yaml, which Compose loads automatically: bind mounts,runserver, published ports,DEBUG. - Put production settings in
compose.prod.yamland rundocker compose -f compose.yaml -f compose.prod.yaml up -d. - Never publish PostgreSQL or Redis ports in production; keep them on the internal network.
- Check the merged result with
docker compose -f compose.yaml -f compose.prod.yaml config. - Set
COMPOSE_FILE=compose.yaml:compose.prod.yamlin the server's environment so plaindocker composeuses the right pair.
Step-by-step solution
1. The shared base
# compose.yaml
services:
web:
image: ghcr.io/you/myapp:${IMAGE_TAG:-latest}
env_file: .env
depends_on:
db: { condition: service_healthy }
redis: { condition: service_healthy }
worker:
image: ghcr.io/you/myapp:${IMAGE_TAG:-latest}
command: celery -A config worker -l info
env_file: .env
depends_on: [db, redis]
db:
image: postgres:16
env_file: .env.db
volumes: [pgdata:/var/lib/postgresql/data]
healthcheck: { test: ["CMD-SHELL", "pg_isready -U app"], interval: 5s, retries: 10 }
redis:
image: redis:7
healthcheck: { test: ["CMD", "redis-cli", "ping"], interval: 5s }
volumes:
pgdata:
2. Development override (auto-loaded)
# compose.override.yaml
services:
web:
build: .
command: python manage.py runserver 0.0.0.0:8000
volumes: [".:/app"]
ports: ["8000:8000"]
environment: { DEBUG: "1", DJANGO_SETTINGS_MODULE: config.settings.development }
worker:
build: .
volumes: [".:/app"]
db:
ports: ["5432:5432"]
docker compose up merges compose.yaml and compose.override.yaml automatically, so developers need no extra flags.
3. Production override
# compose.prod.yaml
services:
web:
command: gunicorn -c gunicorn.conf.py config.wsgi:application
environment: { DJANGO_SETTINGS_MODULE: config.settings.production }
restart: unless-stopped
expose: ["8000"]
logging: { driver: local, options: { max-size: "20m", max-file: "5" } }
worker:
restart: unless-stopped
db:
restart: unless-stopped
redis:
restart: unless-stopped
caddy:
image: caddy:2
ports: ["80:80", "443:443"]
volumes: ["./Caddyfile:/etc/caddy/Caddyfile:ro", "caddy_data:/data"]
restart: unless-stopped
volumes:
caddy_data:
Only the reverse proxy publishes ports. The database and Redis are reachable only on Compose's internal network.
4. Make the server use the right files
On the production host, add to the deploy user's environment or a .env read by Compose:
COMPOSE_FILE=compose.yaml:compose.prod.yaml
Now docker compose up -d, docker compose logs, and docker compose run --rm web python manage.py migrate all use the production pair, and nobody accidentally starts the dev override on the server. Do not copy compose.override.yaml to production hosts at all.
5. Check the merge
docker compose -f compose.yaml -f compose.prod.yaml config | less
Merging rules: single values (like command) are replaced, while lists like ports and volumes are merged, which is why ports belong only in overrides, never in the base. Use !reset (Compose 2.24+) to clear an inherited list when needed.
Verification check: on the server, docker compose config | grep -A3 "db:" shows no ports, ss -ltnp shows only 80 and 443 listening publicly, and docker compose ps shows the web service running Gunicorn.
Explanation
Compose reads files in order and merges them into one model: mappings are combined, scalar values from later files win, and sequences such as ports are concatenated. The automatic loading of compose.override.yaml exists precisely for local development tweaks. Putting production differences in an explicitly selected file, and keeping list-valued keys out of the base, gives each environment a predictable configuration from a shared definition.
Edge cases / notes
- Profiles (
profiles: [debug]) are an alternative for optional services such as Mailpit or pgAdmin in development. extendscan reuse a service definition within or across files for workers that share most settings with web.- Secrets stay in
.envfiles that are not committed; Compose variable substitution reads.envfrom the project directory. - Staging can have its own
compose.staging.yamlon top of the base.
Next steps
- Run the stack properly with Deploy Django With Docker Compose in Production.
- Review it with the Django Docker Production Checklist.
FAQ
What is compose.override.yaml for?
Local development tweaks. Compose loads it automatically on top of compose.yaml.
How do I use a production Compose file?
Pass both files with -f compose.yaml -f compose.prod.yaml, or set COMPOSE_FILE on the server.
Why does my production stack still publish the database port?
A ports list in the base merges into every environment. Keep ports only in overrides.
How do I see the final merged configuration?
Run docker compose config with the same files.