log in
consulting hosting industries the daily tools about contact

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.

Related

Need help shipping something like this? Get in touch.