Skip to main content

Command Palette

Search for a command to run...

Top 16 Docker Compose Best Practices Every Developer Should Know

Updated
16 min read
Top 16 Docker Compose Best Practices Every Developer Should Know
V
Hi there! I’m a DevOps enthusiast, certified in AWS and Terraform, passionate about crafting innovative cloud solutions. From designing scalable CI/CD pipelines to deploying microservices on cloud platforms, I’ve immersed myself in transforming ideas into impactful technologies.

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

  1. Understand Service Startup Order (It's Not What You Think)

  2. Master the Services-Networks-Volumes Triangle

  3. Choose Image vs Build Strategically

  4. Use container_name Only When Necessary

  5. Set Appropriate Restart Policies

  6. Manage Environment Variables Correctly

  7. Control Port Exposure Wisely

  8. Use the Right Volume Type for Each Use Case

  9. Implement Network Segmentation

  10. Leverage Multiple Compose Files for Different Environments

  11. Use Profiles for Selective Service Startup

  12. Always Set Resource Limits in Production

  13. Configure Log Rotation to Prevent Disk Issues

  14. Never Store Secrets in Compose Files

  15. Implement Health Checks for Reliability

  16. 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 image

  • Your application is in development: Use build

  • Your application in production: Use pre-built image with version tags

  • CI/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: no

  • Production applications: on-failure or unless-stopped

  • Critical infrastructure (databases): unless-stopped

  • Development: no or on-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?

  1. Shell environment (highest priority)

  2. environment: in the compose file

  3. env_file: specified files

  4. .env file (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 expose

  • User-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 container

  • Named volumes (volume-name:/path): Docker-managed persistence

  • Anonymous 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 -v flag)

  • 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:

  1. Run without limits in staging

  2. Monitor actual usage with docker stats

  3. Add 20-30% buffer

  4. 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_period longer than the slowest startup

  • Don'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

  1. Used depends_on with health check conditions

  2. Explicitly defined networks and volumes

  3. Chosen appropriate restart policies

  4. Set resource limits for production

  5. Configured log rotation

  6. No secrets in compose files

  7. Implemented health checks

  8. Used extension fields for shared config

  9. Set up multiple files for different environments

  10. Only published necessary ports

  11. Used correct volume types

  12. 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

More from this blog

devopsbyvishu

18 posts