Designing Reusable Docker Compose Configs for Dev and Prod
Docker Compose is the standard tool for defining and running multi-container applications. But many teams fall into the trap of maintaining separate docker-compose.yml files for development, staging, and production — leading to duplication, drift, and configuration bugs.
A better approach uses compose file overlays, profiles, and environment variables to keep a single source of truth that adapts to each environment.
The Base compose.yml Pattern
Start with a single compose.yml that defines the shared service definitions — the image, command, ports, and environment that remain the same across environments.
# compose.yml
services:
app:
image: myapp:${APP_VERSION:-latest}
build:
context: .
target: ${BUILD_TARGET:-development}
ports:
- "${APP_PORT:-3000}:3000"
environment:
- NODE_ENV=${NODE_ENV:-development}
- DATABASE_URL=${DATABASE_URL}
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASS}
POSTGRES_DB: ${DB_NAME}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
interval: 10s
timeout: 5s
retries: 5
volumes:
pgdata:
Use environment variables with defaults (${VAR:-default}) to keep the base file generic.
Environment Overrides with compose.override.yml
Docker Compose automatically reads compose.override.yml if it exists. Use this for development-only settings — never commit an override that would affect production.
# compose.override.yml (excluded from production deployments)
services:
app:
build:
target: development
volumes:
- .:/app
- /app/node_modules
ports:
- "9229:9229" # debugger
environment:
- NODE_ENV=development
- DEBUG=app:*
command: npm run dev
db:
ports:
- "5432:5432" # expose for local tools
volumes:
- ./scripts/seed:/docker-entrypoint-initdb.d
For production, use a separate override file:
# compose.prod.yml
services:
app:
build:
target: production
restart: always
deploy:
resources:
limits:
cpus: '2'
memory: 1G
reservations:
cpus: '0.5'
memory: 256M
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
Deploy with: docker compose -f compose.yml -f compose.prod.yml up -d
Using Profiles for Environment Variants
Profiles (Docker Compose 2.0+) let you conditionally enable services based on the environment.
services:
mailhog:
image: mailhog/mailhog
profiles: ["development", "staging"]
ports:
- "8025:8025"
redis:
image: redis:7-alpine
profiles: ["development", "staging", "production"]
redis-commander:
image: rediscommander/redis-commander
profiles: ["development"]
ports:
- "8081:8081"
Run docker compose --profile development up to start only the services relevant to development.
Health Checks and Restart Policies
Always define health checks for every service that supports them. Combine with restart policies for self-healing deployments.
services:
app:
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--spider", "http://localhost:3000/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 40s
worker:
restart: on-failure:5
depends_on:
app:
condition: service_healthy
The depends_on.condition: service_healthy ensures the worker only starts after the app passes its health check.
Resource Limits and Scaling
Prevent a runaway container from taking down the host by setting resource limits.
services:
app:
deploy:
resources:
limits:
cpus: '1'
memory: 512M
reservations:
cpus: '0.25'
memory: 128M
worker:
deploy:
mode: replicated
replicas: 3
resources:
limits:
cpus: '2'
memory: 1G
Note: deploy settings apply to Docker Swarm and Compose v3 stacks. For plain docker compose, use --compatibility flag or platform-specific limits.
Secrets Management
Never hardcode secrets in compose files. Use Docker secrets or environment files with restricted permissions.
# Using Docker secrets (Swarm mode)
secrets:
db_password:
file: ./secrets/db_password.txt
services:
app:
secrets:
- db_password
environment:
- DATABASE_URL=postgres://user:${DB_PASSWORD}@db:5432/myapp
For non-Swarm setups, use .env files:
# .env (never committed to version control)
DB_USER=myapp
DB_PASS=s3cret!
DB_NAME=myapp
APP_PORT=3000
Logging Configuration
Centralized logging prevents container log spam from filling your disk.
services:
app:
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# Production logging to a remote service
log-collector:
logging:
driver: "fluentd"
options:
fluentd-address: "localhost:24224"
tag: "docker.{{.Name}}"
Conclusion
Designing reusable Docker Compose configurations is about separation of concerns: a base compose.yml for shared definitions, override files for environment-specific settings, profiles for conditional services, and environment variables for secrets and configuration.
This layered approach eliminates duplication, reduces configuration drift, and makes your deployment pipeline predictable across development, staging, and production. Adopt these patterns to ship with confidence.
