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 composeCLI) installedFamiliarity 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.
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
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.
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
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
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"
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.
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]
Recreate:
docker compose up -d
Expected Outcome:
db_datavolume 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];
}](../../../../_images/graphviz-1ce83a12b3be0fc5844ada3664bf5179ece2d324.png)
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];
}](../../../../_images/graphviz-eb25e53fea4bf36ec979a12148483cac3e093d2b.png)
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];
}](../../../../_images/graphviz-eb5c037c0eb8e8c6a62fe35a6b2e30818413a7b4.png)
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];
}](../../../../_images/graphviz-23c6e48a6f1f4e4281d11bb8048fd2dcf2c73bc8.png)
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];
}](../../../../_images/graphviz-f8494e6eef9b1e3e32802c9f80f7508c913fcdaf.png)
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
profilesrun by default.A service with a
profileslist 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-argand multi-stage builds.Mount read-only volumes where possible.
Configure
restart: unless-stoppedfor 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-reloadprod: caches, workers, hard resource limitsobservability: metrics and dashboards
Defining profiles
Add
profiles: ["name1", "name2"]under the service that should be optional.Services without a
profileskey 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-SHELLwith clear timeouts/retries; don’t make them too strict.Rebuilds: Use
--buildwhen 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
latestin production.Profiles: Keep core infrastructure (
db) profile-less; optional tooling behind profiles.