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.

Layered Compose filescompose.yamlshared servicesoverride.yamldev, auto-loadedcompose.prod.yamlprod onlyMerged configper environmentThe base defines the stack; each override changes only what differs for that environment.
  1. Put shared service definitions in compose.yaml (images, environment file names, networks, volumes, health checks).
  2. Put development tweaks in compose.override.yaml, which Compose loads automatically: bind mounts, runserver, published ports, DEBUG.
  3. Put production settings in compose.prod.yaml and run docker compose -f compose.yaml -f compose.prod.yaml up -d.
  4. Never publish PostgreSQL or Redis ports in production; keep them on the internal network.
  5. Check the merged result with docker compose -f compose.yaml -f compose.prod.yaml config.
  6. Set COMPOSE_FILE=compose.yaml:compose.prod.yaml in the server's environment so plain docker compose uses 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.

merged configuration per environmentdev (base + override)prod (base + prod)web commandrunserver✓gunicornsource codebind mount✓baked into imagedb port 5432published✓internal onlyrestart policynone✓unless-stoppedDEBUG1✓offThe same base file produces two very different stacksdjango-deployment.com
Review the merged output with docker compose config before deploying.

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.

dev and prod from one baseNo ports in the base filelists merge, they do not replaceDev override auto-loaded locallycompose.override.yamlProd override set by COMPOSE_FILEon the serverOnly the proxy publishes ports in proddb and redis internalMerged config revieweddocker compose configMost leaks come from list merging putting dev ports into productiondjango-deployment.com
A CI step that runs docker compose config catches mistakes before they reach the server.

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.
  • extends can reuse a service definition within or across files for workers that share most settings with web.
  • Secrets stay in .env files that are not committed; Compose variable substitution reads .env from the project directory.
  • Staging can have its own compose.staging.yaml on top of the base.

Next steps

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.

Learning path · step 5 of 12

Docker, from image to production

Keep going