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
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
--buildon 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
- Create the base file with shared service definitions.
- Create the dev override (
docker-compose.override.yml) with hot-reload, debug ports, and development environment variables. - Create the production override (
docker-compose.prod.yml) with resource limits, healthchecks, and production secrets. - Run
docker compose up— it auto-merges the dev override. - 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 configto 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 upwith-fand 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
- Use Docker Compose profiles with
--profile devor--profile prodto activate different service subsets from a single file. - Employ environment-specific
.envfiles (.env.dev,.env.prod) loaded viaenv_filefor cleaner separation of variables. - 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.ymlfor development-specific settings — it auto-merges without extra flags. - Production overrides need explicit
-f docker-compose.yml -f docker-compose.prod.ymlto avoid dev configurations leaking. - Always validate your merged configuration with
docker compose configbefore 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
.envfiles, Docker secrets, or secret management services.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.