Docker & Docker Compose Best Practices Guide

This guide compiles valuable tips and structured approaches to enhance the architecture, quality, and maintainability of your Docker projects, covering both Dockerfiles and Docker Compose.


I. Dockerfile Best Practices

Optimizing your Dockerfile is the foundation for efficient and secure container images.

Choose the Right Base Image

Select minimal, trusted base images:

  • Use Docker Official Images or Verified Publisher images when possible.
  • Consider Alpine-based images (e.g., node:18-alpine) for significantly smaller footprints, but be aware of potential compatibility differences (musl vs glibc).
  • Pin specific versions (e.g., ubuntu:22.04, node:18.17.1-alpine) instead of using floating tags like latest or alpine to ensure reproducibility and avoid unexpected breaking changes.

Multi-stage Builds for Smaller Images

Multi-stage builds dramatically reduce your final image size by separating build-time dependencies from runtime needs. Only copy necessary artifacts to the final stage.

FROM node:18 AS builder # Build stage
WORKDIR /app
COPY package*.json ./
RUN npm install # Install all deps, including devDeps
COPY . .
RUN npm run build # Create build artifacts in /app/dist

FROM node:18-alpine AS production # Final stage
WORKDIR /app
# Copy only build artifacts and necessary package files from builder stage
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package*.json ./
RUN npm install --production # Install only runtime deps
USER node
CMD ["node", "dist/server.js"]

Use BuildKit Cache Mounts (for Dependencies)

Leverage BuildKit cache mounts to speed up dependency installation during builds without bloating image layers. Enable BuildKit (often default in newer Docker versions, or use `DOCKER_BUILDKIT=1`).

# Example for npm
RUN --mount=type=cache,target=/root/.npm npm install

# Example for apt
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
    apt-get update && apt-get install -y ...

# Example for pip
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

This caches package manager data between builds, significantly speeding up installs if dependency files haven't changed.

Don't Install Unnecessary Packages

Only install packages absolutely required for your application to run. Combine installation and cleanup in a single RUN command to minimize layer size.

RUN apt-get update && apt-get install -y --no-install-recommends \
    package1 package2 \
    && rm -rf /var/lib/apt/lists/* # Clean up apt cache

Follow Instruction Best Practices

  • Use proper FROM instructions with specific versions (see Base Image).
  • Order instructions logically to maximize cache usage (install dependencies before copying frequently changing code).
  • Combine related RUN commands using && and backslashes (\) for readability, and clean up temporary files within the same layer.
  • Use WORKDIR to set context instead of chaining cd in RUN commands. Use absolute paths for clarity (e.g., WORKDIR /app).
  • Use COPY over ADD unless you specifically need ADD's auto-extraction or URL features.
  • Add meaningful LABEL metadata (maintainer, description, version).
  • Use a non-root USER for security.
  • Define HEALTHCHECK, EXPOSE, VOLUME as appropriate.
  • Understand the difference between ENTRYPOINT and CMD.

Review Language-Specific Examples

Docker provides guides and examples tailored to specific programming languages and frameworks. Reviewing these can help you adopt conventional practices for your stack.


II. Docker Compose Best Practices

Docker Compose helps define and run multi-container applications. These practices improve Compose configurations.

Decouple Applications into Separate Containers

Each container should ideally have a single responsibility (e.g., web server, database, cache, message queue). This promotes the "one process per container" principle.

  • Split your web application, database, and cache into separate services in your compose.yaml.
  • Use Docker networks (Compose creates a default one, or you can define custom networks) to enable communication between these services. Services can reach each other using their service name as the hostname (e.g., `db` from the `app` service).

Understand the Compose Application Model

Be familiar with the core concepts defined in a Compose file:

  • Services: Define the containers that make up your application.
  • Networks: Define how containers connect and communicate.
  • Volumes: Define persistent data storage.
  • Configs & Secrets: Define ways to securely provide configuration and sensitive data.

Use Compose for Development Consistency

Docker Compose is excellent for creating reproducible development environments.

  • Define all required services (app, db, cache, etc.) in compose.yaml.
  • Use volumes to mount your source code into the container for live updates (e.g., volumes: - ./src:/app/src).
  • Control startup order using depends_on with condition: service_healthy, ensuring dependencies are ready.
services:
  web:
    build: .
    ports:
      - "8080:80"
    volumes:
      - ./src:/app/src # Mount source code
    depends_on:
      db:
        condition: service_healthy # Wait for db healthcheck
  db:
    image: postgres:15
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "postgres"]
      interval: 10s
      timeout: 5s
      retries: 5
    # ... other db config

Optimize Service Definitions

Review the configuration for each service in your Compose file:

  • Set appropriate restart policies (e.g., restart: unless-stopped for production services).
  • Define meaningful health checks (healthcheck) so Docker knows if your service is running correctly.
  • Use environment variables (environment: or env_file:) for configuration, but prefer secrets for sensitive data.
  • Consider setting resource limits (deploy: resources: limits: ...) in production environments, especially when using Swarm or Kubernetes.

Use Secrets for Sensitive Data

Avoid hardcoding passwords, API keys, or other sensitive information directly in your Compose file or environment variables. Use Docker secrets instead.

services:
  app:
    image: my-app
    environment:
      DB_PASSWORD_FILE: /run/secrets/db_password # App reads from this file
    secrets:
      - db_password # Grant access to the secret

  db:
    image: postgres:15
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password # Postgres reads from this file
    secrets:
      - db_password

secrets:
  db_password:
    file: ./secrets/db_password.txt # Source the secret from this host file

Secrets are mounted as files into /run/secrets/ within the container, preventing exposure in logs or via docker inspect.

Leverage Service Extension (extends)

Use the extends attribute to share common configurations between services or across multiple Compose files, promoting the DRY (Don't Repeat Yourself) principle.

# common-services.yaml
services:
  base-app:
    restart: unless-stopped
    networks:
      - backend

# compose.yaml
services:
  web:
    extends:
      file: common-services.yaml
      service: base-app
    build: ./web
    ports: ["80:80"]

  api:
    extends:
      file: common-services.yaml
      service: base-app
    build: ./api

Optimize for Different Environments

Use multiple Compose files to manage configurations for different environments (e.g., development, testing, production).

  • Create a base compose.yaml with common configurations.
  • Create override files like compose.dev.yaml, compose.prod.yaml.
  • Use the -f flag to specify which files to merge:
    docker compose -f compose.yaml -f compose.prod.yaml up
  • Alternatively, use profiles (see Project Structure section) to enable/disable services based on environment.

Study Sample Applications

Explore the Awesome Compose repository for examples of how to structure Compose projects for various technology stacks.


III. Recommended Project Structure

A well-organized folder structure makes managing multi-service applications with different environments much easier.

Folder Structure Example

my-app/
├── docker/                     # Docker related files
│   ├── common/                 # Shared Dockerfile stages/components (optional)
│   │   └── base.Dockerfile
│   ├── development/            # Development-specific Dockerfiles
│   │   ├── service1/
│   │   │   └── Dockerfile
│   │   └── service2/
│   │       └── Dockerfile
│   └── production/             # Production-specific Dockerfiles
│       ├── service1/
│       │   └── Dockerfile
│       └── service2/
│           └── Dockerfile
├── services/                   # Application code for each service
│   ├── service1/
│   │   ├── src/                # Service1 source code
│   │   └── .dockerignore       # Service-specific dockerignore
│   └── service2/
│       ├── src/                # Service2 source code
│       └── .dockerignore       # Service-specific dockerignore
├── secrets/                    # Directory for secret files (add to .gitignore!)
│   └── db_password.txt
├── compose.yaml                # Base Compose file (common services/config)
├── compose.dev.yaml            # Development environment overrides
├── compose.prod.yaml           # Production environment overrides
└── .dockerignore               # Root dockerignore
└── .env                        # Environment variables file (optional, add to .gitignore!)

Key Components Explained

  • docker/ directory: Centralizes Dockerfiles, separating them by environment (dev/prod) and potentially service. Allows tailored builds (debug tools in dev, optimized multi-stage in prod).
  • services/ directory: Contains the actual source code for each distinct application component or microservice.
  • Compose Files (root):
    • compose.yaml: Defines the core, common service configurations, networks, volumes.
    • compose.dev.yaml / compose.prod.yaml: Override or add configurations specific to an environment (e.g., different ports, volume mounts for code, different Dockerfiles, environment variables).
  • secrets/ directory: Holds plain text secret files referenced by the secrets: file: directive in Compose. Crucially, add this directory to your .gitignore!
  • .env file: Can be used to set default environment variables for Compose using env_file: or variable substitution (${VAR_NAME}). Add this to .gitignore if it contains secrets! Prefer Docker secrets for sensitive data.

Using Profiles

Profiles allow defining subsets of services that can be enabled selectively, useful for optional tools or specific deployment scenarios.

services:
  frontend:
    # ... config
    profiles: [frontend, dev] # Runs if 'frontend' OR 'dev' profile is active

  phpmyadmin:
    # ... config
    profiles: [debug] # Only runs if 'debug' profile is active

  backend:
    # ... config
    # No profile means always enabled (unless ALL services have profiles)

Activate profiles using the --profile flag:

# Run default services + services with 'dev' profile
docker compose --profile dev up

# Run only services with 'debug' profile (+ defaults if any)
docker compose --profile debug up -d

.dockerignore Files

Exclude files/directories from the build context sent to the Docker daemon to speed up builds, reduce image size, and avoid leaking sensitive files.

  • Root .dockerignore: Applies by default when the build context is the root (.). Ignore common files like .git, node_modules, *.log, secrets/, .env.
  • Service-specific .dockerignore: Place inside a service directory (e.g., services/service1/.dockerignore) if the build context is set to that directory in compose.yaml.

Handling Multiple Environments

Combine base and environment-specific Compose files using the -f flag:

# Development (merges compose.yaml and compose.dev.yaml)
docker compose -f compose.yaml -f compose.dev.yaml up -d --build

# Production (merges compose.yaml and compose.prod.yaml)
docker compose -f compose.yaml -f compose.prod.yaml up -d

This structured approach helps manage overrides and keeps relative paths within Compose files consistent.


IV. Common Workflows & Commands

Addressing specific tasks like refining projects and rebuilding services.

Refining Projects: A Structured Approach

Follow these steps to systematically optimize your Docker project:

  1. Start with your Dockerfile(s): Optimize image builds first (base image, multi-stage, caching, minimal installs).
  2. Optimize your Compose file(s): Define services correctly, manage environments, use secrets, configure networking/volumes/dependencies.
  3. Test and Iterate: Regularly rebuild, test thoroughly in different environments, and consider CI/CD integration.

Focus on the individual components (Dockerfiles) first, then the orchestration (Compose).

Rebuilding Services Completely

To rebuild images and recreate containers, possibly removing old artifacts:

  • Rebuild & Recreate Containers:
    docker compose up --build --force-recreate
    Builds images and forces container recreation, but doesn't remove old images automatically.
  • Force Fresh Build (No Cache):
    docker compose build --no-cache
    Disables the build cache entirely for all layers. Follow with docker compose up.
  • Prune Old Images, Then Build:
    docker system prune -f --filter "until=24h"
    docker compose build
    Removes unused images first, then builds (potentially using cache for unchanged layers).
  • Remove Containers, Then Build/Up:
    docker compose rm -f -s -v
    docker compose build
    docker compose up
    Removes containers (and optionally volumes), builds fresh images, then starts new containers.
  • Development Watch & Prune:
    docker compose watch --prune
    Automatically rebuilds on changes and removes dangling images during development.

Most Thorough Approach (Clean Slate):

docker system prune -a -f # Prune all unused objects
docker compose build --no-cache # Rebuild without cache
docker compose up -d # Start fresh containers

Testing and Iteration

  • Rebuild Images Regularly: Keep base images and dependencies up-to-date with security patches. Use docker compose build --pull to ensure base images are updated, or use --no-cache periodically. [Reference]
  • Test Configuration: Verify services start, communicate correctly, and function as expected using different environment configurations (e.g., dev vs prod Compose files).
  • CI/CD Integration: Automate building, testing (unit, integration, end-to-end), and deployment of your Dockerized application for consistency and reliability.

V. Detailed Examples

Full examples of Dockerfiles and Docker Compose files incorporating many of the practices discussed.

Comprehensive Dockerfile Example

Illustrates various instructions and techniques in a single file.

# syntax=docker/dockerfile:1
FROM ubuntu:22.04

LABEL maintainer="Your Name <your.email@example.com>" version="1.0"

ENV APP_HOME=/app NODE_ENV=production DEBIAN_FRONTEND=noninteractive

ARG VERSION=1.0
ARG UID=1000 GID=1000
RUN groupadd --gid $GID appuser && \
    useradd --uid $UID --gid $GID --shell /bin/bash --create-home appuser

WORKDIR $APP_HOME

RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
    apt-get update && apt-get install -y --no-install-recommends curl nodejs npm python3 python3-pip \
    && apt-get clean && rm -rf /var/lib/apt/lists/*

COPY package*.json requirements.txt ./

RUN --mount=type=cache,target=/root/.npm npm install --production
RUN --mount=type=cache,target=/root/.cache/pip pip3 install --no-cache-dir -r requirements.txt

COPY . .

RUN chown -R appuser:appuser $APP_HOME
USER appuser

RUN --mount=type=cache,target=/app/node_modules/.cache npm run build

EXPOSE 8080
VOLUME ["/app/data", "/app/logs"]

HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
    CMD curl -f http://localhost:8080/health || exit 1

ENTRYPOINT ["./docker-entrypoint.sh"]
CMD ["npm", "start"]

Multi-Stage Dockerfile Example

Demonstrates separating build and runtime concerns for smaller production images.

# syntax=docker/dockerfile:1

# --- Build Stage ---
FROM node:18-alpine AS builder
WORKDIR /build
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm install
COPY . .
RUN --mount=type=cache,target=/build/node_modules/.cache npm run build

# --- Production Stage ---
FROM node:18-alpine AS production
WORKDIR /app
COPY --from=builder /build/dist ./dist
COPY --from=builder /build/package*.json ./
RUN --mount=type=cache,target=/root/.npm npm install --production
USER node
EXPOSE 8080
CMD ["node", "dist/server.js"]

Standard Docker Compose Example

A typical compose.yaml defining multiple services, networks, and volumes.

services:
  app:
    build:
      context: .
      dockerfile: ./docker/production/app/Dockerfile
      args:
        BUILD_ENV: production
    container_name: my-application
    restart: unless-stopped
    ports:
      - "8080:80"
    environment:
      - NODE_ENV=production
      - DB_HOST=db
      - DB_USER=prod_user
      - DB_PASSWORD=supersecret # Avoid this, use secrets!
    volumes:
      - app-data:/var/data
    depends_on:
      db:
        condition: service_healthy
    networks:
      - frontend
      - backend
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:80/health"]
      interval: 30s
      timeout: 10s
      retries: 3

  db:
    image: postgres:14-alpine
    container_name: database
    restart: always
    environment:
      POSTGRES_USER: prod_user
      POSTGRES_PASSWORD: supersecret # Avoid this, use secrets!
      POSTGRES_DB: production_db
    volumes:
      - db-data:/var/lib/postgresql/data
    networks:
      - backend
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U prod_user -d production_db"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  app-data:
  db-data:

networks:
  frontend:
  backend:

Docker Compose with Secrets Example

Demonstrates the secure way to handle sensitive data using Docker secrets.

services:
  app:
    build:
      context: .
      dockerfile: ./docker/production/app/Dockerfile
      secrets: # Build-time secrets (optional)
        - npm_token
    container_name: my-application-secure
    restart: unless-stopped
    ports:
      - "8080:80"
    environment:
      NODE_ENV: production
      DB_HOST: db
      DB_USER_FILE: /run/secrets/db_user # Path to user secret file
      DB_PASSWORD_FILE: /run/secrets/db_password # Path to password secret file
      API_KEY_FILE: /run/secrets/ext_api_key
    volumes:
      - app-data:/var/data
    depends_on:
      db:
        condition: service_healthy
    networks:
      - frontend
      - backend
    healthcheck: # (same as above)
      test: ["CMD", "curl", "-f", "http://localhost:80/health"]
      interval: 30s
      timeout: 10s
      retries: 3
    secrets: # Grant runtime access to these secrets
      - db_user
      - db_password
      - ext_api_key

  db:
    image: postgres:14-alpine
    container_name: database-secure
    restart: always
    environment:
      POSTGRES_USER_FILE: /run/secrets/db_user # Postgres reads user from file
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password # Postgres reads password from file
      POSTGRES_DB: production_db
    volumes:
      - db-data:/var/lib/postgresql/data
    networks:
      - backend
    healthcheck: # Needs adjustment if user is from secret
      test: ["CMD-SHELL", "pg_isready -d production_db -U `cat /run/secrets/db_user`"]
      interval: 10s
      timeout: 5s
      retries: 5
    secrets: # Grant runtime access
      - db_user
      - db_password

volumes:
  app-data:
  db-data:

networks:
  frontend:
  backend:

secrets:
  db_user:
    file: ./secrets/db_user.txt
  db_password:
    file: ./secrets/db_password.txt
  ext_api_key: # Example using host environment variable
    environment: EXTERNAL_API_KEY
  npm_token: # Build-time secret
    file: ./secrets/npm_token.txt

This guide combines best practices and examples from Docker's documentation to help you build efficient, secure, and maintainable containerized applications.