Maintenance

Site is under maintenance — quizzes are still available.

Go to quizzes

Compose Dev vs Prod

Learn to configure Docker Compose for development and production environments, covering environment-specific overrides, volume mounts, and best practices.

Focus: use compose for development vs production

Sponsored

You can write the same docker-compose.yml file for both development and production — but you shouldn't. Development needs hot-reload, debugging ports, and relaxed security; production demands zero downtime, resource limits, and hardened secrets. If you lump both into a single configuration, you end up with a container that works nowhere well. This lesson shows you how to keep separate Compose files for each environment, override just what changes, and avoid the most common pitfalls.

The problem this lesson solves

A single docker-compose.yml forces painful compromises. You might leave volume mounts that expose your source code in production, or lock down debug ports that your local workflow needs. The result: either your production image is fragile because it trusts developer-friendly defaults, or your dev environment is slow because it mimics production safeguards.

The real cost

  • Development friction: No live reload, slow image rebuilds on every file change, and blocked debug ports.
  • Production risk: Accidental data exposure, orphaned volumes, and security breaches from dev-only configurations.
  • Maintenance overhead: One team wants --build on every change, another wants pinned images — constant argument in code review.

Docker Compose solves this with override files and profiles. You define a base file for shared services, then layer environment-specific changes on top.

Core concept / mental model

Think of Docker Compose files as environment layers, not complete blueprints. The base docker-compose.yml holds what never changes: image names, network topology, and persistent volumes. Then you create override files — docker-compose.override.yml for development and docker-compose.prod.yml for production.

The default override

When you run docker compose up, Docker automatically merges docker-compose.override.yml if present. This is your development layer. You don't need to pass any flags — it just works.

Explicit production override

For production, you specify the override file explicitly:

docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
# docker-compose.yml (base)
services:
  web:
    image: myapp:latest
    ports:
      - "80:80"
    environment:
      - LOG_LEVEL=info
# docker-compose.override.yml (dev)
services:
  web:
    build: .
    ports:
      - "8000:8000"
    environment:
      - LOG_LEVEL=debug
    volumes:
      - .:/app
# docker-compose.prod.yml (production)
services:
  web:
    deploy:
      replicas: 3
      resources:
        limits:
          cpus: '0.5'
          memory: 512M
    environment:
      - DATABASE_URL=postgres://prod-db:5432/app

The base file declares the service and a default image. The dev override adds build context and volume mounts for live reload. The production override adds replicas, resource limits, and a different database URL.

How it works step by step

  1. Create the base file with shared service definitions.
  2. Create the dev override (docker-compose.override.yml) with hot-reload, debug ports, and development environment variables.
  3. Create the production override (docker-compose.prod.yml) with resource limits, healthchecks, and production secrets.
  4. Run docker compose up — it auto-merges the dev override.
  5. Run for production with docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d.

The merge process

Compose merges files in order: first file wins for scalar values (like image), arrays are appended (like ports), and maps are unioned (like environment). If the dev override sets command: npm run dev and the base sets command: npm start, dev wins.

Pro tip: Use docker compose config to see the final merged result before running. It prints the full resolved YAML — perfect for debugging overrides.

Hands-on walkthrough

Let's build a real project with a Node.js app and a PostgreSQL database.

Step 1: Create the base docker-compose.yml

version: '3.8'
services:
  app:
    image: myapp:${TAG:-latest}
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - DB_HOST=db
    depends_on:
      - db
  db:
    image: postgres:15-alpine
    environment:
      - POSTGRES_USER=app
      - POSTGRES_PASSWORD=${DB_PASSWORD}
    volumes:
      - pgdata:/var/lib/postgresql/data
volumes:
  pgdata:

Step 2: Create docker-compose.override.yml (development)

services:
  app:
    build: .
    image: myapp:dev
    ports:
      - "3000:3000"
      - "9229:9229"  # Node.js debugger
    environment:
      - NODE_ENV=development
      - DB_HOST=localhost
    volumes:
      - .:/app
      - /app/node_modules
    command: npm run dev
  db:
    ports:
      - "5432:5432"
    environment:
      - POSTGRES_PASSWORD=devpass

Step 3: Create docker-compose.prod.yml

services:
  app:
    image: myapp:${TAG:-latest}
    deploy:
      replicas: 3
      resources:
        limits:
          cpus: '0.5'
          memory: 512M
        reservations:
          cpus: '0.2'
          memory: 128M
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
    environment:
      - DB_HOST=db
      - DB_PASSWORD=${DB_PROD_PASSWORD}
  db:
    environment:
      - POSTGRES_PASSWORD=${DB_PROD_PASSWORD}
    volumes:
      - prod_pgdata:/var/lib/postgresql/data
volumes:
  prod_pgdata:

Step 4: Run it

# Development (auto-picks override)
docker compose up

# Production
export TAG=v1.0.1
export DB_PROD_PASSWORD=strongsecret
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

Expected output (development):

[+] Running 3/3
 ✔ Network ... created
 ✔ Volume ... created
 ✔ Container app-1  Created
Attaching to app-1
app-1  | > app@1.0.0 dev
app-1  | > nodemon src/index.js

Expected output (production):

[+] Running 4/4
 ✔ Network ... created
 ✔ Volume ... created
 ✔ Container app-1  Started
 ✔ Container db-1   Started

Compare options / when to choose what

Factor Single file Base + override Multi-profile (profiles)
Complexity Low Medium High
Environment separation None Clear and explicit Magic keywords
Security Poor Excellent Good
Portability Widely compatible Very compatible Requires Compose v2.20+
Debugging ease Hard Easy with config Confusing profiles
  • Single file: Only for quick prototypes or tiny projects where dev and prod are identical.
  • Base + override: The industry standard — clear, explicit, and works with any CI/CD.
  • Multi-profile: Use when you have more than two environments (staging, QA, canary) and need fine-grained activation via --profile.

Troubleshooting & edge cases

Override not applying

docker compose config  # check the final YAML

If you see base values instead of override, verify: - You have both files in the same directory - The override file is named exactly docker-compose.override.yml (case-sensitive) - You didn't pass -f for dev — that disables automatic override lookup

Port conflicts

If development uses ports: "3000:3000" and production also uses that, you get a conflict. Solution: Use a different host port in production (e.g., 3001:3000) via environment variables.

# docker-compose.prod.yml
services:
  app:
    ports:
      - "${HOST_PORT:-3000}:3000"

Persistent data crossing environments

The same named volume (pgdata) is used in both environments. That means dev data leaks into production if you deploy from the same host. Solution: Use distinct volume names per environment via /var/lib/docker/volumes/ or prefix with environment variable.

docker-compose.prod.yml
volumes:
  pgdata:
    name: "prod_${COMPOSE_PROJECT_NAME}_pgdata"

Secrets in environment variables

Don't hardcode passwords in override files. Use .env files or Docker secrets:

# .env.prod
db_password=s3cret
services:
  db:
    env_file:
      - .env.prod

What you learned & what's next

You learned how to use Compose for development vs production by separating configurations into layered files. You can: - ✓ Create a base docker-compose.yml with shared service definitions - ✓ Override it with docker-compose.override.yml for development-specific settings like live reload and debug ports - ✓ Override it explicitly with docker-compose.prod.yml for production safety with resource limits and healthchecks - ✓ Validate your final config with docker compose config before deployment - ✓ Handle edge cases like port conflicts and data leakage between environments

This pattern is essential for every multi-service application. Next, you'll learn how to optimize Docker images with multi-stage builds — a technique that reduces image size by separating build-time dependencies from runtime artifacts.

Key takeaway: Compose override files let you write once, deploy everywhere — without compromising development speed or production safety.

Practice recap

Set up a minimal two-service project (a web app + a database). Create docker-compose.yml, docker-compose.override.yml with a volume mount for live reload, and docker-compose.prod.yml with a resource limit and healthcheck. Run docker compose config to verify the merged output for both environments.

Common mistakes

  • Hardcoding secrets like database passwords directly in override files instead of using environment variables or Docker secrets.
  • Forgetting to use distinct volume names per environment — leads to dev data polluting production.
  • Using docker compose up with -f and then wondering why the automatic override didn't apply — ordering matters.
  • Not pinning image tags in production overrides — always use image: myapp:${TAG:-latest} for reproducibility.

Variations

  1. Use Docker Compose profiles with --profile dev or --profile prod to activate different service subsets from a single file.
  2. Employ environment-specific .env files (.env.dev, .env.prod) loaded via env_file for cleaner separation of variables.
  3. Leverage multiple compose files for different deployment targets (staging, canary) by chaining more than two files (e.g., -f base.yml -f staging.yml -f prod.yml).

Real-world use cases

  • E-commerce microservices: Dev gets hot-reload and debug ports; production gets healthchecks and auto-scaling replicas per service.
  • CI/CD pipeline: Separate Compose files for integration test suites (with mocked dependencies) vs production deployment (with real external services).
  • Data science workflows: Development overrides mount local notebooks and volatile data volumes; production uses read-only snapshots and pinned ML model images.

Key takeaways

  • Use docker-compose.override.yml for development-specific settings — it auto-merges without extra flags.
  • Production overrides need explicit -f docker-compose.yml -f docker-compose.prod.yml to avoid dev configurations leaking.
  • Always validate your merged configuration with docker compose config before running in a new environment.
  • Keep base files shared; override only what changes (ports, volumes, environment, deploy settings).
  • Use separate volume names per environment to prevent data cross-contamination.
  • Never hardcode secrets — rely on .env files, Docker secrets, or secret management services.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.