Top 16 Docker Compose Best Practices Every Developer Should Know

Introduction
Image Source: A Photo by MetricsViews Private Limited
Docker Compose has become the de facto standard for defining and running multi-container applications. Whether you're building microservices, setting up development environments, or deploying small-to-medium-sized production applications, Docker Compose significantly simplifies container orchestration.
However, there is a massive gap between writing a compose file that "works" and writing one that's secure, maintainable, and production-ready. After working with Docker Compose across numerous projects and environments, I've compiled the 16 most critical best practices that will transform your Docker Compose configurations from basic to professional-grade.
In this comprehensive guide, you'll learn:
Why certain configurations cause security vulnerabilities and production failures
How to structure and compose files for maximum maintainability
When to use specific features and patterns
Real-world examples demonstrating each best practice
Let's dive in!
Table of Contents
Understand Service Startup Order (It's Not What You Think)
Master the Services-Networks-Volumes Triangle
Choose Image vs Build Strategically
Use container_name Only When Necessary
Set Appropriate Restart Policies
Manage Environment Variables Correctly
Control Port Exposure Wisely
Use the Right Volume Type for Each Use Case
Implement Network Segmentation
Leverage Multiple Compose Files for Different Environments
Use Profiles for Selective Service Startup
Always Set Resource Limits in Production
Configure Log Rotation to Prevent Disk Issues
Never Store Secrets in Compose Files
Implement Health Checks for Reliability
Apply the DRY Principle with Extension Fields
1. Understand Service Startup Order (It's Not What You Think) {understand-service-startup-order}
Why It Matters?
Many developers believe that Docker Compose starts services in the order they appear in the YAML file. This misconception leads to race conditions where applications crash because dependencies aren't ready yet.
What It Is?
Docker Compose starts services in parallel by default, regardless of their order in the file. The only way to control the startup sequence is through explicit depends_on declarations.
When to Use?
Always use depends_on When services have dependencies. However, understand that basic depends_on only waits for the container to start, not for the application inside to be ready.
Best Practice Example
Wrong Assumption:
services:
database:
image: postgres:15
backend:
image: my-backend:latest
# Assumes it starts after database
✅ Correct Approach:
services:
database:
image: postgres:15
healthcheck:
test: ["CMD-EXEC", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
backend:
image: my-backend:latest
depends_on:
database:
condition: service_healthy # Waits for healthy, not just started
Key Takeaway
Service order in YAML means nothing. Use depends_on With health check conditions to ensure proper startup sequence.
2. Master the Services-Networks-Volumes Triangle {master-the-triangle}
Why It Matters?
Understanding how services, networks, and volumes interact is fundamental to Docker Compose. These three components form an interconnected system where each depends on the others.
What It Is?
Services: Your containers (the workers)
Networks: Communication channels (how services talk)
Volumes: Persistence layer (data that survives restarts)
When to Use?
Always define networks and volumes explicitly rather than relying on Docker's auto-generated defaults. This makes your infrastructure clear and controllable.
Best Practice Example
services:
app:
image: myapp:latest
networks:
- frontend-net
- backend-net
volumes:
- app-data:/data
database:
image: postgres:15
networks:
- backend-net # Not on frontend-net (isolation)
volumes:
- db-data:/var/lib/postgresql/data
networks:
frontend-net:
driver: bridge
backend-net:
driver: bridge
volumes:
app-data:
db-data:
Key Takeaway
Explicit network and volume definitions provide better control, security, and documentation of your application's architecture.
3. Choose Image vs Build Strategically {image-vs-build-strategy}
Why It Matters
The choice between using pre-built images and building from Dockerfiles affects deployment speed, consistency, and development workflow.
What It Is
image: Pull pre-built images from registries (fast, consistent)
build: Build images from local Dockerfiles (flexible, customizable)
Hybrid: Build and tag images for the best of both worlds
When to Use
Third-party services (databases, caches): Always use
imageYour application is in development: Use
buildYour application in production: Use pre-built
imagewith version tagsCI/CD pipelines: Use the hybrid approach
Best Practice Example
Development:
services:
app:
build:
context: ./app
dockerfile: Dockerfile
volumes:
- ./app:/app # Live code changes
Production:
services:
app:
image: myregistry.com/app:v1.2.3 # Pre-built, versioned
Hybrid (Best for CI/CD):
services:
app:
build:
context: ./app
image: myregistry.com/app:${VERSION:-latest}
# Build locally, push to registry, pull in production
Key Takeaway
Use image for third-party services and production deployments. Use build for active development. Combine both for optimal CI/CD workflows.
4. Use container_name Only When Necessary {container-name-usage}
Why It Matters?
Named containers cannot be scaled. If you try to run multiple instances with --scaleYou'll get naming conflicts and failures.
What It Is?
container_name assigns a fixed name to a container instead of Docker's auto-generated names (like myapp-web-1, myapp-web-2).
When to Use?
Singleton services (databases, reverse proxies)
When external tools need to reference specific container names
Services that will never need horizontal scaling
Best Practice Example
Avoid for Scalable Services:
services:
api:
image: my-api
container_name: my-api # Can't scale this!
✅ Use for Singletons:
services:
postgres:
image: postgres:15
container_name: production-db # Only one database needed
api:
image: my-api
# No container_name - can scale with: docker compose up --scale api=5
Key Takeaway
Skip container_name for services you might need to scale. Use it only for true singletons.
5. Set Appropriate Restart Policies {restart-policies}
Why It Matters?
Restart policies determine what happens when containers exit. Wrong policies can mask bugs or cause infinite restart loops that consume system resources.
What It Is?
Four restart policies control container behaviour after exit:
no: Never restart (default)
on-failure: Restart only if crashed (exit code != 0)
always: Restart regardless of exit code
unless-stopped: Like always, but respects manual stops
When to Use?
Migration scripts, one-time tasks:
noProduction applications:
on-failureorunless-stoppedCritical infrastructure (databases):
unless-stoppedDevelopment:
nooron-failure(fail fast)
Best Practice Example
services:
# One-time migration
migrate:
image: myapp
command: npm run migrate
restart: no
# Application
api:
image: myapp
restart: on-failure # Restart on crash, not on intentional stop
# Critical infrastructure
postgres:
image: postgres:15
restart: unless-stopped # Always restart except manual stop
Key Takeaway
Use on-failure for applications to catch crashes without masking intentional stops. Use unless-stopped for critical infrastructure.
6. Manage Environment Variables Correctly {environment-variables}
Why It Matters?
Misunderstanding environment variable precedence and purposes leads to a configuration that doesn't work as expected and potentially to security issues.
What It Is?
Three ways to define environment variables, each with different purposes:
.env file: For Compose itself (variable substitution with
${VARIABLE})environment: Hardcoded values in compose file
env_file: External files passed to containers
When to Use?
.env: Image tags, Compose configuration (
image: myapp:${VERSION})environment: Non-sensitive, static values everyone should see
env_file: Multiple variables, different files per environment
Precedence Order?
Shell environment (highest priority)
environment:in the compose fileenv_file:specified files.envfile (lowest priority)
Best Practice Example
.env (for Compose):
VERSION=1.2.3
ENVIRONMENT=production
docker-compose.yml:
services:
app:
image: myapp:${VERSION} # Uses .env file
environment:
NODE_ENV: ${ENVIRONMENT} # Substituted from .env
env_file:
- ./config/common.env
- ./config/${ENVIRONMENT}.env
common.env (for containers):
LOG_LEVEL=info
API_TIMEOUT=30
Key Takeaway
Understand that .env is for Compose substitution, not directly for containers. Use env_file to pass variables into containers.
7. Control Port Exposure Wisely {port-exposure}
Why It Matters?
Publishing ports unnecessarily creates security vulnerabilities by exposing internal services to the host machine and potentially the network.
What It Is?
Three levels of access control:
No configuration: Containers on the same network can communicate (most secure)
expose: Documentation only, doesn't open ports
ports: Maps container ports to host (creates a security risk)
When to Use?
Internal services (databases, caches): No port configuration needed
Documentation purposes: Use
exposeUser-facing services (web apps, APIs): Use
ports
Best Practice Example
services:
# Level 1: Internal only (most secure)
postgres:
image: postgres:15
# No ports! Only accessible from other containers on same network
redis:
image: redis:7-alpine
expose:
- "6379" # Documentation only
# Level 3: Public access (when necessary)
web:
image: nginx
ports:
- "127.0.0.1:8080:80" # Bind to localhost only (more secure)
# NOT "8080:80" which binds to all interfaces (0.0.0.0)
Key Takeaway
Default to no port publishing for internal services. Only publish what absolutely needs external access, and bind to localhost when possible.
8. Use the Right Volume Type for Each Use Case {volume-types}
Why It Matters?
Wrong volume types lead to data loss, poor performance, or the inability to persist data. Each type serves a specific purpose.
What It Is?
Bind mounts (
./path:/path): Maps the host directory to the containerNamed volumes (
volume-name:/path): Docker-managed persistenceAnonymous volumes (
/path): Temporary, Docker-generated
When to Use?
Development (live code editing): Bind mounts
Production data persistence: Named volumes
Cache, temporary files: Anonymous volumes
node_modules, dependencies: Named volumes (avoid bind mount performance hit)
What Survives docker compose down?
Bind mounts: Always (they're your files)
Named volumes: Yes (unless you add
-vflag)Anonymous volumes: No (deleted immediately)
Best Practice Example
Development:
services:
app:
image: node:18
volumes:
- ./src:/app/src # Bind mount for live editing
- node_modules:/app/node_modules # Named volume for speed
- /app/.cache # Anonymous volume for temp files
volumes:
node_modules:
Production:
services:
app:
image: myapp:latest
volumes:
- app-data:/app/data # Named volumes only
- uploads:/app/uploads
volumes:
app-data:
uploads:
Key Takeaway
Bind mounts for development convenience. Named volumes for production reliability. Never use bind mounts in production.
9. Implement Network Segmentation {network-segmentation}
Why It Matters?
A single network means every service can talk to every other service. If one service is compromised, attackers have access to everything, including your database.
What It Is?
Creating multiple networks and assigning services only to the networks they need. Implements the principle of least access.
When to Use?
Any multi-tier application (frontend/backend/data)
Microservices architectures
When security and isolation matter
Best Practice Example
services:
frontend:
image: nginx
networks:
- frontend-net
# Cannot reach database directly
backend:
image: myapi
networks:
- frontend-net # Talks to frontend
- backend-net # Talks to database
database:
image: postgres:15
networks:
- backend-net # Only backend can reach it
# Frontend can't access database even if compromised
networks:
frontend-net:
driver: bridge
backend-net:
driver: bridge
Key Takeaway
Use multiple networks to create security boundaries. Services join only the networks they need. Defence in depth through network isolation.
10. Leverage Multiple Compose Files for Different Environments {multiple-compose-files}
Why It Matters?
Copying entire compose files for each environment leads to duplication and inconsistencies. Multiple files with overrides follow the DRY principle.
What It Is?
docker-compose.yml: Base configuration (shared across environments)
docker-compose.override.yml: Development overrides (auto-loaded)
docker-compose.prod.yml: Production-specific configuration
docker-compose.test.yml: Testing-specific configuration
When to Use?
When you have different configurations for different environments, but want to maintain a single source of truth for common settings.
Merge Behavior
Scalars (strings, numbers): Override replaces base
Arrays (lists): Override merges with base
Maps (objects): Deep merge
Best Practice Example
docker-compose.yml (base):
services:
app:
image: myapp:${VERSION:-latest}
restart: on-failure
postgres:
image: postgres:15
restart: unless-stopped
docker-compose.override.yml (dev, auto-loaded):
services:
app:
build: .
volumes:
- ./src:/app/src
ports:
- "3000:3000"
environment:
DEBUG: "true"
docker-compose.prod.yml (production):
services:
app:
image: myregistry.com/app:${VERSION}
deploy:
replicas: 3
environment:
DEBUG: "false"
Usage:
# Development (automatic)
docker compose up
# Production (explicit)
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
Key Takeaway
One base file, multiple environment overlays. Zero duplication. Easy maintenance.
11. Use Profiles for Selective Service Startup {profiles}
Why It Matters?
Starting all 15 services when you only need 3 wastes resources and slows down development. Profiles let you start only what you need.
What It Is?
Tag services with profile labels. Start only services matching the requested profile(s).
When to Use?
Large compose files with many services
Different development modes (frontend-only, backend-only, full-stack)
Optional tools and utilities (monitoring, debugging)
Best Practice Example
services:
# Core services (no profile = always starts)
postgres:
image: postgres:15
redis:
image: redis:7-alpine
# Backend development
api:
image: myapi
profiles: ["backend", "fullstack"]
depends_on:
- postgres
- redis
# Frontend development
web:
image: myweb
profiles: ["frontend", "fullstack"]
# Debug tools
adminer:
image: adminer
profiles: ["debug"]
ports:
- "8080:8080"
Usage:
# Start only core services
docker compose up
# Frontend development
docker compose --profile frontend up
# Backend development with debug tools
docker compose --profile backend --profile debug up
# Everything
docker compose --profile fullstack up
Key Takeaway
Profiles enable selective startup. Start only what you need for faster iterations and less resource consumption.
12. Always Set Resource Limits in Production {resource-limits}
Why It Matters?
Without resource limits, one misbehaving container can consume all system resources and crash the entire host. Limits provide blast shields.
What It Is?
Limits: Maximum resources a container can use
Reservations: Minimum guaranteed resources
When to Use?
Production: Always (critical for stability)
Shared environments: Mandatory (fair resource sharing)
Development: Optional (convenience)
Memory vs CPU Behaviour
Memory limit exceeded: Container gets OOMKilled (instant death)
CPU limit exceeded: Container gets throttled (runs slower)
Best Practice Example
services:
app:
image: myapp
deploy:
resources:
limits:
cpus: '2.0'
memory: 512M
reservations:
cpus: '0.5'
memory: 256M
database:
image: postgres:15
deploy:
resources:
limits:
cpus: '4.0'
memory: 2G
reservations:
cpus: '1.0'
memory: 1G
How to Calculate Limits:
Run without limits in staging
Monitor actual usage with
docker statsAdd 20-30% buffer
Set that as your limit
Key Takeaway
Resource limits isolate failures. One container hitting its limit won't kill other containers. Always set them in production.
13. Configure Log Rotation to Prevent Disk Issues {log-rotation}
Why It Matters?
Docker's default logging has no size limits. Logs grow forever until they fill your disk, causing system failures.
What It Is?
Logging drivers control where logs go and how they're managed. The default json-file The driver needs a rotation configuration.
When to Use?
Always: Set log rotation in all environments
Production: Use centralised logging (ELK, Datadog, CloudWatch)
Development: Local logs with rotation
Best Practice Example
services:
app:
image: myapp
logging:
driver: json-file
options:
max-size: "10m" # Max 10MB per file
max-file: "3" # Keep 3 files max (30MB total)
database:
image: postgres:15
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5" # 250MB total for database logs
Production with Centralised Logging:
services:
app:
image: myapp
logging:
driver: fluentd
options:
fluentd-address: logs.example.com:24224
tag: app-logs
Key Takeaway
Default logging = unlimited growth = disk disaster. Always set max-size and max-file. Use centralised logging for production.
14. Never Store Secrets in Compose Files {secrets-management}
Why It Matters?
Secrets committed to Git end up in version history forever. GitHub's secret scanner finds them, and they end up in breach databases.
What It Is?
Passwords, API keys, certificates, and tokens need special handling. They should never be in compose files or committed to Git.
When to Use?
Anytime you need to handle sensitive information.
Docker Secrets Reality
Docker secrets feature requires Swarm mode. Standalone Compose has limited support (file-based only, not encrypted).
Best Practice Example
Never Do This:
services:
app:
environment:
DATABASE_PASSWORD: super_secret_123 # In Git forever!
✅ Development Approach:
# docker-compose.yml (committed)
services:
app:
env_file:
- .env # NOT committed (.gitignore)
# .env.example (committed as template)
DATABASE_PASSWORD=changeme
API_KEY=your-key-here
# .env (NOT committed, actual secrets)
DATABASE_PASSWORD=actual_secret
API_KEY=actual_key
# .gitignore
.env
secrets/
✅ File-based Secrets (Compose):
services:
app:
secrets:
- db_password
secrets:
db_password:
file: ./secrets/db_password.txt # NOT in Git
✅ Production (External Secret Manager):
# Secrets injected via environment at deployment time
# Using AWS Secrets Manager, Vault, etc.
services:
app:
image: myapp
# No secrets in compose file
# Injected by deployment pipeline
Key Takeaway
Never commit secrets. Use .env files (gitignored) for development. Use external secret managers for production.
15. Implement Health Checks for Reliability {health-checks}
Why It Matters
Container running ≠ Application ready. Without health checks, dependent services start too early and crash.
What It Is
Health checks test if the application inside the container is actually ready to handle requests.
When to Use
Any service that takes time to initialise (databases, applications)
Services with dependencies (combined with
depends_on)Production deployments (critical for orchestration)
Best Practice Example
services:
postgres:
image: postgres:15
healthcheck:
test: ["CMD-EXEC", "pg_isready -U postgres"]
interval: 10s # Check every 10 seconds
timeout: 5s # Max 5 seconds per check
retries: 5 # 5 failures = unhealthy
start_period: 30s # 30s grace period on startup
app:
image: myapp
depends_on:
postgres:
condition: service_healthy # Wait for healthy, not just started
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
Health Check Best Practices:
Keep checks lightweight (shallow, not deep)
Set
start_periodlonger than the slowest startupDon't query external services in health checks
Return quickly to avoid overhead
Key Takeaway
Health checks ensure proper startup order and readiness. Always combine them with depends_on for reliable service orchestration.
16. Apply DRY Principle with Extension Fields {extension-fields}
Why It Matters?
Repeating the same configuration across 10 services leads to inconsistencies and maintenance nightmares. Change one setting? Update 10 places.
What It Is?
Extension fields (x- prefix) Create reusable templates that services can inherit from.
When to Use?
Multiple services sharing a common configuration
Standardised logging, health checks, and resource limits
Compose files over 100 lines
Best Practice Example
# Define reusable templates
x-logging: &default-logging
driver: json-file
options:
max-size: "10m"
max-file: "3"
x-healthcheck: &default-healthcheck
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
x-resources: &default-resources
limits:
cpus: '2'
memory: 512M
reservations:
cpus: '0.5'
memory: 256M
# Use templates in services
services:
api:
image: myapi
logging: *default-logging
healthcheck:
<<: *default-healthcheck
test: ["CMD", "curl", "-f", "http://localhost/health"]
deploy:
resources: *default-resources
worker:
image: myworker
logging: *default-logging
healthcheck:
<<: *default-healthcheck
test: ["CMD", "/app/healthcheck.sh"]
deploy:
resources: *default-resources
scheduler:
image: myscheduler
logging: *default-logging
# Inherits all common config
Benefits:
Define once, use everywhere
Change once, update everywhere
Guaranteed consistency
Easier maintenance
Key Takeaway
Extension fields eliminate duplication and ensure consistency. Use them for any shared configuration across multiple services.
Conclusion
These 16 best practices transform Docker Compose configurations from basic to production-grade:
Security: Network segmentation, no exposed ports, secrets management.
Reliability: Health checks, proper startup order, restart policies
Performance: Resource limits, log rotation, and correct volume types
Maintainability: Extension fields, multiple files, profiles
Scalability: Avoid container names, use profiles, and proper dependencies
Quick Checklist for Your Next Compose File
Used
depends_onwith health check conditionsExplicitly defined networks and volumes
Chosen appropriate restart policies
Set resource limits for production
Configured log rotation
No secrets in compose files
Implemented health checks
Used extension fields for shared config
Set up multiple files for different environments
Only published necessary ports
Used correct volume types
Applied network segmentation
What's Next?
Start implementing these practices incrementally. You don't need to refactor everything at once. Pick the most critical issues (like secrets management or log rotation) and work through the list.
Your future self and your team will thank you when debugging issues, scaling services, or responding to security incidents.
Additional Resources
Have questions or additional best practices to share? Drop a comment below! I would love to hear about your Docker Compose experiences and challenges.
Found this helpful? Share it with your team and follow me for more deep-dives into DevOps, containers, and cloud-native technologies.
#Docker #DockerCompose #DevOps #Containers #BestPractices #Microservices #CloudNative #SoftwareEngineering #Infrastructure #DevSecOps #Production #DeveloperTools #Backend #CloudComputing #TechBlog




