Docker Compose

Introduction

The Docker Compose tool allows you to define and manage multi-container Docker applications using simple YAML files. This article guides you through creating a robust development and production-ready stack using Docker Compose, covering essential features like service definitions, networking, volumes, healthchecks, scaling, and profiles.

  • Define multi-service stacks

  • Wire up networks, volumes, healthchecks, and dependencies

  • Use profiles for dev/test/observability extras

  • Scale services

  • Prepare a production-oriented override

Prerequisites

  • Docker Engine and Docker Compose (docker compose CLI) installed

  • Familiarity with Docker images/containers, volumes, and networks)

Example Project Structures

Minimal (two services)

This example structure includes a simple web app and Nginx reverse proxy.

compose-labs/
├── app/
│   ├── Dockerfile
│   ├── requirements.txt
│   └── src/
│       └── app.py
├── nginx/
│   └── default.conf
├── docker-compose.yml
├── .env
└── docs/
    └── index.rst

Full (dev + prod, profiles, tests, CI)

This example structure includes additional services, profiles, and testing setup.

compose-labs/
├── app/
│   ├── Dockerfile
│   ├── requirements.txt
│   └── src/
│       ├── app.py
│       └── db.py
├── worker/
│   ├── Dockerfile
│   └── worker.py
├── nginx/
│   └── default.conf
├── compose/
│   ├── docker-compose.yml              # Base stack (web, app, db, cache)
│   ├── docker-compose.override.yml     # Dev overrides (bind mounts, watch, debug)
│   ├── docker-compose.prod.yml         # Prod overrides (limits, restart, secrets)
│   └── .env.example
├── migrations/
├── tests/
│   └── test_smoke.py
├── .env
├── Makefile
└── docs/
    ├── conf.py
    └── index.rst

First Compose Stack

Objective: Define a single-container service (Nginx) with port mapping.

In this exercise, we will create a simple Docker Compose file that defines a single service running Nginx, a popular web server. We will map port 8080 on the host to port 80 in the container so that we can access the Nginx welcome page from our browser.

  1. Create the folder and base file:

     1# Create a directory named 'compose-labs' if it doesn't exist
     2mkdir -p compose-labs && cd compose-labs
     3
     4# Create a new file named 'docker-compose.yml' and write the following YAML content into it
     5cat > docker-compose.yml <<'YAML'
     6services:                   # Define the services for Docker Compose
     7    web:                    # Service name: 'web'
     8    image: nginx:latest     # Use the latest official NGINX image from Docker Hub
     9    ports:
    10        - "8080:80"         # Map host port 8080 to container port 80 (HTTP)
    11YAML
    
  2. Up the stack:

    docker compose up -d
    docker compose ps
    

Expected Outcome: Open http://localhost:8080 and see the Nginx welcome page.

Add an App Service

Objective: Add a Python API and a reverse proxy.

Next, we will add a simple Python Flask application that serves a health check endpoint. We will also configure Nginx to act as a reverse proxy, forwarding requests from port 80 to the Flask app running on port 5000.

  1. Create the app:

     1# Create the directory structure: 'app/src'
     2mkdir -p app/src
     3
     4# Create a Dockerfile inside 'app' directory
     5cat > app/Dockerfile <<'DOCKER'
     6# Use a lightweight Python 3.13 base image
     7FROM python:3.13-slim
     8WORKDIR /app
     9COPY requirements.txt .
    10RUN pip install --no-cache-dir -r requirements.txt
    11COPY src/ src/
    12EXPOSE 5000
    13CMD ["python", "src/app.py"]
    14DOCKER
    15
    16# Create a requirements.txt file with Python dependencies
    17cat > app/requirements.txt <<'REQ'
    18flask==3.0.3
    19gunicorn==22.0.0
    20REQ
    21
    22# Create the Flask application in app/src/app.py
    23cat > app/src/app.py <<'PY'
    24from flask import Flask, jsonify
    25# Initialize the Flask application
    26app = Flask(__name__)
    27
    28# Define a GET endpoint for health check
    29@app.get("/api/health")
    30def health():
    31    # Return a JSON response with status "ok"
    32    return jsonify(status="ok")
    33
    34# Run the app on all network interfaces at port 5000
    35if __name__ == "__main__":
    36    # Enable external access when running in Docker
    37    app.run(host="0.0.0.0", port=5000)
    38PY
    
  2. Add Nginx config:

     1mkdir -p nginx
     2cat > nginx/default.conf <<'NGINX'
     3server {
     4    listen 80;
     5    location / {
     6        proxy_pass http://app:5000;
     7        proxy_set_header Host $host;
     8        proxy_set_header X-Real-IP $remote_addr;
     9    }
    10}
    11NGINX
    
  3. Update docker-compose.yml:

     1# Root section defining all services managed by Docker Compose
     2services:
     3# Reverse proxy / static web server
     4  web:
     5    # Use the official NGINX image (latest tag)
     6    image: nginx:latest
     7
     8    # Mount configuration files into the container and map local nginx/default.conf to container path (read-only)
     9    volumes:
    10      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    11
    12    # Publish and expose container port 80 on host port 8080
    13    ports:
    14      - "8080:80"
    15
    16    # Specify startup order for services. Ensure the 'app' service is started before 'web'
    17    depends_on:
    18      - app
    19
    20  # Backend application service
    21  app:
    22    # Build the image from the Dockerfile in ./app
    23    build: ./app
    24
    25    # Make port available to other services on the Compose network. Internal port used by the app (not published to host)
    26    expose:
    27      - "5000"
    
  4. Up:

    docker compose up -d --build
    curl -s http://localhost:8080/api/health
    

Expected Outcome: {"status":"ok"} is returned via Nginx → app.

Network, Environment, and Volumes

Objective: Add Postgres with a named volume; wire up app via environment variables and a user-defined network.

At this point, we have three services: web, app, and db (Postgres). We will create a private network for backend communication, persist Postgres data with a named volume, and configure the app to connect to the database using environment variables. Additionally, we will set up service dependencies to ensure proper startup order. The services will be able to communicate with each other using their service names as hostnames.

  1. Extend Compose:

     1# Define custom networks for inter-service communication
     2networks:
     3  # Private backend network shared by web, app, and db
     4  backend:
     5
     6# Volume to persist PostgreSQL data across container restarts
     7volumes:
     8  db_data:
     9
    10services:
    11  web:
    12    # ... as before ...
    13    networks: [backend]
    14  app:
    15    # ... as before ...
    16    # Inject the database connection string into the app
    17    environment:
    18      DATABASE_URL: "postgresql://appuser:apppass@db:5432/appdb"
    19    # Attach the app service to the private backend network
    20    networks: [backend]
    21    # Start app only after db is ready
    22    depends_on:
    23      - db
    24  db:
    25    image: postgres:16-alpine
    26    # Configure database credentials and default database
    27    environment:
    28      POSTGRES_USER: appuser
    29      POSTGRES_PASSWORD: apppass
    30      POSTGRES_DB: appdb
    31    # Persist database files outside the container
    32    volumes:
    33      - db_data:/var/lib/postgresql/data
    34    # Attach the database to the private backend network
    35    networks: [backend]
    
  2. Recreate:

    docker compose up -d
    

Expected Outcome:

  • db_data volume persists Postgres data.

  • Services can reach each other via service names on backend.

Healthchecks and Conditional Start

Objective: Ensure app waits until Postgres is ready.

Add healthcheck to db and modify app’s depends_on.

 1services:
 2  db:
 3    image: postgres:16-alpine
 4    # ... as before ...
 5    healthcheck:
 6      # Simple command to check if Postgres is ready to accept connections
 7      test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
 8      # Healthcheck settings
 9      interval: 5s
10      timeout: 3s
11      retries: 10
12
13  app:
14    build: ./app
15    # ... as before ...
16    depends_on:
17      db:
18        # Condition to wait for healthy status
19        condition: service_healthy

Check:

docker compose ps
docker compose logs db --tail=50

Expected Outcome: app starts after db reports healthy.

Scaling the App

Objective: Run multiple replicas of app.

Next, we will scale the app service to run multiple instances. This is useful for load balancing and improving the availability of our application. We will use Docker Compose’s scaling feature to achieve this.

docker compose up -d --scale app=3

Note

Nginx is a simple reverse proxy here and does not do health-based load balancing; in this setup it will round-robin across IPs known on the network.

Dev Overrides and Live Code

Objective: Use docker-compose.override.yml to mount source and enable an auto-reloader.

In development, it’s common to want to see code changes reflected immediately without rebuilding images. We will create a Docker Compose override file that mounts the application source code into the container and enables Flask’s debug mode for live reloading.

Example Workflow

Create docker-compose.override.yml (auto-applied):

1services:
2  app:
3    # Mount local source code into the container for live editing
4    volumes:
5      - ./app/src:/app/src
6    # Enable Flask debug mode and auto-reload
7    command: python -m flask --app src/app.py run --debug --host=0.0.0.0 --port=5000
8    environment:
9      FLASK_DEBUG: "1"

Restart:

docker compose up -d --build

Edit files under app/src and refresh your browser.

Profiles - I

Objective: Use profiles to conditionally include services and developer tools.

Profiles allow you to define optional services that can be activated based on the environment or use case. As an example, we will add a Redis cache, a background worker, and developer tools like Mailhog and Adminer, each behind specific profiles. Mailhog is a web-based email testing tool, and Adminer is a lightweight database management tool. The profiles will help us keep the base stack clean while enabling additional functionality as needed.

  • Production Profile: Includes Redis cache and worker for background tasks.

  • Development Profile: Adds Mailhog for email testing and Adminer for database management.

  • QA Profile: Includes Adminer for database management.

Base Architecture

The overall architecture with profiles looks like this:

digraph compose_architecture {
  rankdir=LR;
  fontsize=12;
  fontname="Arial";

  // Networks
  subgraph cluster_frontend {
    label="Frontend Network";
    style=dashed;
    web [shape=oval, style=filled, fillcolor=yellow];
  }

  subgraph cluster_backend {
    label="Backend Network";
    style=dashed;
    app [shape=oval, style=filled, fillcolor=yellow];
    db [shape=oval, style=filled, fillcolor=yellow];
    cache;
    worker;
    mailhog;
    adminer;
  }

  // Volumes
  db_data [shape=cylinder, label="db_data", fillcolor=lightblue, style=filled];
  redis_data [shape=cylinder, label="redis_data", fillcolor=lightblue, style=filled];

  // Services
  web -> app [label="depends_on"];
  app -> db [label="DATABASE_URL"];
  app -> cache [label="REDIS_URL"];
  worker -> db [label="DATABASE_URL"];
  worker -> cache [label="REDIS_URL"];
  db -> db_data [label="volume"];
  cache -> redis_data [label="volume"];

  // Profiles
  mailhog [shape=box, style="filled,dotted", fillcolor=lightgrey, label="mailhog\ndev only"];
  adminer [shape=box, style="filled,dotted", fillcolor=lightgrey, label="adminer\ndev, qa"];
  worker [shape=box, style="filled,dotted", fillcolor=lightgrey, label="worker\ndev, qa, prod"];
  cache [shape=box, style="filled,dotted", fillcolor=lightgrey, label="cache\ndev, prod"];

  // Overrides
  app_override [shape=note, label="app override\n(dev: live reload)"];
  prod_limits [shape=note, label="prod limits\nCPU & memory"];

  app -> app_override [style=dotted];
  app -> prod_limits [style=dotted];
  worker -> prod_limits [style=dotted];
  web -> prod_limits [style=dotted];
}

Overall Architecture with Profiles

The base architecture without profiles looks like this:

digraph base {
  rankdir=LR;
  fontsize=12;
  fontname="Arial";
  node [shape=oval, style=filled, fillcolor=yellow];

  web -> app [label="depends_on"];
  app -> db [label="DATABASE_URL"];
  db -> db_data [label="volume"];

  subgraph cluster_backend {
    style=dashed;
    label="Backend Network";
    db; app;
  }

  subgraph cluster_frontend {
    style=dashed;
    label="Frontend Network";
    web;
  }

  // Volumes
  db_data [shape=cylinder, label="db_data", fillcolor=lightblue, style=filled];
}

Base Architecture

Base file: compose/docker-compose.yml

 1name: compose-labs
 2
 3networks:
 4  backend:
 5  frontend:
 6
 7volumes:
 8  db_data:
 9  redis_data:
10
11services:
12  web:
13    image: nginx:1.27-alpine
14    volumes:
15      - ../nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
16    ports:
17      - "8080:80"
18    depends_on: [app]
19    networks: [frontend, backend]
20
21  app:
22    build: ../app
23    environment:
24      DATABASE_URL: postgresql://appuser:apppass@db:5432/appdb
25      REDIS_URL: redis://cache:6379/0
26    expose: ["5000"]
27    depends_on:
28      db:
29        condition: service_healthy
30    networks: [backend]
31
32  db:
33    image: postgres:16-alpine
34    environment:
35      POSTGRES_USER: appuser
36      POSTGRES_PASSWORD: apppass
37      POSTGRES_DB: appdb
38    healthcheck:
39      test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
40      interval: 5s
41      timeout: 3s
42      retries: 10
43    volumes:
44      - db_data:/var/lib/postgresql/data
45    networks: [backend]
46
47  # Optional cache for performance (enabled in dev and prod)
48  cache:
49    image: redis:7-alpine
50    volumes:
51      - redis_data:/data
52    command: ["redis-server", "--appendonly", "yes"]
53    networks: [backend]
54    profiles: ["dev", "prod"]
55
56  # Background worker (optional), consumes jobs from Redis
57  worker:
58    build: ../worker
59    environment:
60      DATABASE_URL: postgresql://appuser:apppass@db:5432/appdb
61      REDIS_URL: redis://cache:6379/0
62    depends_on:
63      db:
64        condition: service_healthy
65      cache:
66        condition: service_started
67    networks: [backend]
68    profiles: ["dev", "qa", "prod"]
69
70  # Dev-only helpers
71  mailhog:
72    image: mailhog/mailhog:v1.0.1
73    ports:
74      - "8025:8025"
75    networks: [backend]
76    profiles: ["dev"]
77
78  adminer:
79    image: adminer:4
80    ports:
81      - "8081:8080"
82    networks: [backend]
83    profiles: ["dev", "qa"]

Below, we define profile-specific overrides for development and production environments. In the development override, we mount the application source code into the container and enable Flask’s debug mode for live reloading. In the production override, we set resource limits and restart policies for the services.

Development Profile

The development profile includes live-reload capabilities and developer tools like Mailhog and Adminer.

digraph dev {
  rankdir=LR;
  fontsize=12;
  fontname="Arial";
  node [shape=box];

  web -> app [label="depends_on"];
  app -> db [label="DATABASE_URL"];
  app -> cache [label="REDIS_URL"];
  worker -> db [label="DATABASE_URL"];
  worker -> cache [label="REDIS_URL"];
  db -> db_data [label="volume"];
  cache -> redis_data [label="volume"];

  subgraph cluster_backend {
    label="Backend Network";
    style=dashed;
    app [shape=oval, style=filled, fillcolor=yellow];
    db [shape=oval, style=filled, fillcolor=yellow];
    cache [shape=box, style="filled,dotted", fillcolor=lightgrey, label="cache"];
    worker [shape=box, style="filled,dotted", fillcolor=lightgrey, label="worker"];

    mailhog [shape=box, style="filled,dotted", fillcolor=lightgrey, label="mailhog"];
    adminer [shape=box, style="filled,dotted", fillcolor=lightgrey, label="adminer"];

  }

  subgraph cluster_frontend {
    label="Frontend Network";
    style=dashed;
    web [shape=oval, style=filled, fillcolor=yellow];
  }

  // Volumes
  db_data [shape=cylinder, label="db_data", fillcolor=lightblue, style=filled];
  redis_data [shape=cylinder, label="redis_data", fillcolor=lightblue, style=filled];

}

Development Profile Architecture

Dev override: compose/docker-compose.override.yml

1services:
2  app:
3    # Mount local source code into the container for live editing
4    volumes:
5      - ../app/src:/app/src
6    environment:
7      FLASK_DEBUG: "1"
8    command: python -m flask --app src/app.py run --debug --host=0.0.0.0 --port=5000

Production Profile

The production profile includes resource limits and restart policies for critical services.

digraph prod {
  rankdir=LR;
  fontsize=12;
  fontname="Arial";
  node [shape=box];

  web -> app [label="depends_on"];
  app -> db [label="DATABASE_URL"];
  app -> cache [label="REDIS_URL"];
  worker -> db [label="DATABASE_URL"];
  worker -> cache [label="REDIS_URL"];
  db -> db_data [label="volume"];
  cache -> redis_data [label="volume"];

  subgraph cluster_backend {
    label="Backend Network";
    style=dashed;
    db [shape=oval, style=filled, fillcolor=yellow];
    app [shape=oval, style=filled, fillcolor=yellow];
    cache [shape=box, style="filled,dotted", fillcolor=lightgray];
    worker [shape=box, style="filled,dotted", fillcolor=lightgray];
  }

  subgraph cluster_frontend {
    label="Frontend Network";
    style=dashed;
    web [shape=oval, style=filled, fillcolor=yellow];
  }

  // Volumes
  db_data [shape=cylinder, label="db_data", fillcolor=lightblue, style=filled];
  redis_data [shape=cylinder, label="redis_data", fillcolor=lightblue, style=filled];
}

Production Profile Architecture

Prod override: compose/docker-compose.prod.yml

 1  services:
 2    app:
 3      # Set resource limits and restart policy for production.
 4      # Note that live-reload and bind mounts are omitted here.
 5      deploy:
 6        resources:
 7          limits:
 8            cpus: "1.0"
 9            memory: "512M"
10      restart: unless-stopped
11    worker:
12      deploy:
13        resources:
14          limits:
15            cpus: "0.5"
16            memory: "256M"
17      restart: unless-stopped
18    web:
19      restart: unless-stopped

QA Profile

The QA profile includes Adminer for database management and the worker service, but omits developer live-reload features.

QA override: N/A (uses base + adminer + worker profiles)

digraph qa {
  rankdir=LR;
  fontsize=12;
  fontname="Arial";
  node [shape=box];

  web -> app [label="depends_on"];
  app -> db [label="DATABASE_URL"];
  worker -> db [label="DATABASE_URL"];
  db -> db_data [label="volume"];

  subgraph cluster_backend {
    label="Backend Network";
    style=dashed;
    app [shape=oval, style=filled, fillcolor=yellow];
    db [shape=oval, style=filled, fillcolor=yellow];
    worker[shape=box, style="filled,dotted", fillcolor=lightgrey, label="worker"];
    adminer [shape=box, style="filled,dotted", fillcolor=lightgrey, label="adminer"];
  }

  subgraph cluster_frontend {
    label="Frontend Network";
    style=dashed;
    web [shape=oval, style=filled, fillcolor=yellow];
  }

  // Volumes
  db_data [shape=cylinder, label="db_data", fillcolor=lightblue, style=filled];

}

QA Profile Architecture

Run with profiles:

# Dev profile (includes cache, worker, tools)
cd compose
docker compose --profile dev up -d

# QA profile (worker + adminer, but not developer live-reload if you omit override)
docker compose --profile qa up -d

# Prod base + prod overrides
docker compose --profile prod -f docker-compose.yml -f docker-compose.prod.yml up -d

# Alternatively via env var:
COMPOSE_PROFILES=dev,observability docker compose up -d

Note

  • Services without profiles run by default.

  • A service with a profiles list runs only when any listed profile is active.

  • Starting a specific service may pull in its dependencies even if those dependencies carry different profiles.

Testing and One-Off Commands

Objective: Run commands inside services.

Next, we will demonstrate how to run one-off commands inside our services using Docker Compose. This is useful for tasks such as running tests, database migrations, or executing administrative commands.

1# Run tests inside the app container
2docker compose exec app pytest -q
3
4# Check Postgres tables
5docker compose exec db psql -U appuser -d appdb -c "\dt"
6
7# Run a one-off command in the worker container
8docker compose run --rm worker python -c "print('hello from worker')"

Observability (Optional Profile)

Objective: Add a lightweight metrics stack under observability profile.

A lightweight observability stack can help monitor the health and performance of your services. We will add Prometheus for metrics collection and Grafana for visualization, both behind an observability profile.

Snippet (to append in docker-compose.yml):

 1services:
 2  # Collect metrics with Prometheus
 3  prometheus:
 4    image: prom/prometheus:v2.55.0
 5    volumes:
 6      - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
 7    ports:
 8      - "9090:9090"
 9    networks: [backend]
10    profiles: ["observability"]
11
12  # Visualize metrics with Grafana
13  grafana:
14    image: grafana/grafana:11.2.0
15    ports:
16      - "3000:3000"
17    networks: [backend]
18    profiles: ["observability"]

Run:

docker compose --profile observability up -d

Ship a Production-Like Stack

Objective: Use resource limits, restart policies, externalized configuration, and minimized images.

To prepare your Docker Compose stack for production, consider the following best practices:

Checklist

  • Pin images to specific tags (e.g., nginx:1.27-alpine).

  • Use --build-arg and multi-stage builds.

  • Mount read-only volumes where possible.

  • Configure restart: unless-stopped for long-running services.

  • Store secrets in environment files mounted as files, or Docker secrets if Swarm/Kubernetes.

Profiles - II

What are profiles?

Profiles let you conditionally include services in your application model. They enable you to keep a single Compose file (with optional overrides) and activate subsets of services for different scenarios, such as:

  • dev: developer conveniences (hot reload, admin tools, Mailhog)

  • qa: worker + DB admin, but no dev auto-reload

  • prod: caches, workers, hard resource limits

  • observability: metrics and dashboards

Defining profiles

  • Add profiles: ["name1", "name2"] under the service that should be optional.

  • Services without a profiles key are always active.

Activating profiles

  • CLI: docker compose --profile <name> up -d (repeat flag for multiple)

  • Env var: COMPOSE_PROFILES=name1,name2 docker compose up -d

Selection Rules (practical tips)

  • Activating a profile enables all services listing that profile.

  • If you explicitly start a service, Compose starts its dependencies even if the dependencies belong to other profiles.

  • Keep “core” dependencies (e.g., db) profile-less to avoid surprises.

  • Put tooling (e.g., Adminer, Mailhog, Grafana) behind a profile.

  • Use profile-specific overrides for ports/mounts to avoid conflicts.

Troubleshooting

  • Port conflicts: Move dev helpers behind profiles and only activate when needed.

  • Healthchecks: Prefer CMD-SHELL with clear timeouts/retries; don’t make them too strict.

  • Rebuilds: Use --build when you change Dockerfiles or dependencies.

  • Caching: Bind mounts bypass image layers; if behavior differs between dev/prod, test with bind mounts off.

  • Images: Pin to specific tags; avoid latest in production.

  • Profiles: Keep core infrastructure (db) profile-less; optional tooling behind profiles.

References