Cristhian Villegas
DevOps12 min read2 views

Docker Course #6: Docker Compose — Multi-Container Applications

Docker Course #6: Docker Compose — Multi-Container Applications

Welcome to the Docker Course - Part 6 of 10. In this article, you will learn how to use Docker Compose to define and manage multi-container applications with a single configuration file.

Docker Logo

Source: Wikimedia Commons

Up until now, we have been running individual containers with docker run. But real-world applications often need multiple services working together: a web server, a database, a cache, a message queue, and more. Managing all of these individually with separate docker run commands quickly becomes impractical.

Docker Compose solves this problem by letting you define all your services, networks, and volumes in a single YAML file and manage them with simple commands.

Prerequisites: You should be comfortable with Docker images, containers, volumes, and networks from the previous articles in this course. If you need a refresher, go back to Parts 1 through 5.

What Is Docker Compose?

Docker Compose is a tool for defining and running multi-container Docker applications. With Compose, you use a YAML file to configure your application's services, networks, and volumes. Then, with a single command, you create and start all the services from your configuration.

Key benefits of Docker Compose:

  • Single file configuration: All services defined in one docker-compose.yml (or compose.yml) file
  • One-command deployment: Start everything with docker compose up
  • Environment consistency: Everyone on the team uses the same setup
  • Isolated environments: Compose creates a dedicated network for your services
  • Reproducibility: The same file works on any machine with Docker installed

Docker Compose comes built into Docker Desktop and modern Docker Engine installations. You use it with the docker compose command (note: the older docker-compose with a hyphen is deprecated).

Tip: Check your Docker Compose version with docker compose version. You should be using Compose V2 or later for the best experience.

The docker-compose.yml Structure

The Compose file is a YAML file that defines your entire application stack. Here is the basic structure:

yaml
1# docker-compose.yml (or compose.yml)
2
3# Optional: specify the Compose file version (modern Compose V2 does not require this)
4# version: "3.8"
5
6services:
7  # Each service is a container
8  service-name:
9    image: image-name:tag
10    ports:
11      - "host-port:container-port"
12    environment:
13      - KEY=value
14    volumes:
15      - volume-name:/path/in/container
16    networks:
17      - network-name
18
19volumes:
20  volume-name:
21
22networks:
23  network-name:

Let us break down each top-level key:

Key Purpose
services Defines the containers that make up your application
volumes Declares named volumes for persistent data
networks Declares custom networks for inter-service communication

Services, Networks, and Volumes

Let us explore each of these core concepts in detail.

Services

A service is a container definition. Each service specifies what image to use (or how to build it), what ports to expose, what environment variables to set, and more.

yaml
1services:
2  web:
3    image: nginx:alpine
4    ports:
5      - "8080:80"
6    volumes:
7      - ./html:/usr/share/nginx/html:ro
8    restart: unless-stopped
9
10  api:
11    build:
12      context: ./api
13      dockerfile: Dockerfile
14    ports:
15      - "3000:3000"
16    environment:
17      - NODE_ENV=production
18      - DB_HOST=database
19    depends_on:
20      - database
21
22  database:
23    image: postgres:16-alpine
24    environment:
25      POSTGRES_DB: myapp
26      POSTGRES_USER: appuser
27      POSTGRES_PASSWORD: secret123
28    volumes:
29      - db-data:/var/lib/postgresql/data
30    ports:
31      - "5432:5432"
32
33volumes:
34  db-data:

Common service configuration options:

  • image - The Docker image to use
  • build - Build from a Dockerfile instead of pulling an image
  • ports - Port mappings (host:container)
  • environment - Environment variables (list or map format)
  • volumes - Volume mounts (named volumes or bind mounts)
  • restart - Restart policy (no, always, unless-stopped, on-failure)
  • command - Override the default command
  • depends_on - Service dependencies

Networks

By default, Compose creates a single network for your application. All services can reach each other using the service name as hostname. You can also define custom networks for isolation:

yaml
1services:
2  frontend:
3    image: nginx:alpine
4    networks:
5      - frontend-net
6
7  api:
8    image: node:20-alpine
9    networks:
10      - frontend-net
11      - backend-net
12
13  database:
14    image: postgres:16-alpine
15    networks:
16      - backend-net
17
18networks:
19  frontend-net:
20  backend-net:

In this example, the frontend can talk to the api but cannot directly reach the database. The api sits in both networks and acts as the bridge.

Volumes

Named volumes persist data across container restarts and recreations:

yaml
1services:
2  database:
3    image: mysql:8
4    volumes:
5      - mysql-data:/var/lib/mysql       # Named volume
6      - ./init.sql:/docker-entrypoint-initdb.d/init.sql  # Bind mount
7
8volumes:
9  mysql-data:
10    driver: local

Essential Docker Compose Commands

Here are the commands you will use every day with Docker Compose:

bash
1# Start all services (detached mode)
2docker compose up -d
3
4# Start all services (foreground with logs)
5docker compose up
6
7# Stop all services
8docker compose down
9
10# Stop and remove volumes (WARNING: deletes data!)
11docker compose down -v
12
13# View running services
14docker compose ps
15
16# View logs for all services
17docker compose logs
18
19# View logs for a specific service (follow mode)
20docker compose logs -f api
21
22# Restart a specific service
23docker compose restart api
24
25# Rebuild images and start
26docker compose up -d --build
27
28# Execute a command in a running service
29docker compose exec database psql -U appuser -d myapp
30
31# Scale a service (run multiple instances)
32docker compose up -d --scale api=3
33
34# Pull latest images
35docker compose pull
36
37# View resource usage
38docker compose top
Warning: The command docker compose down -v removes named volumes and all their data. Use it with caution, especially with database volumes. Use docker compose down (without -v) to stop services while keeping your data.

Environment Variables in Compose

Docker Compose offers several ways to manage environment variables:

Method 1: Inline in the Compose file

yaml
1services:
2  api:
3    image: myapi:latest
4    environment:
5      - DATABASE_URL=postgresql://user:pass@db:5432/myapp
6      - REDIS_URL=redis://cache:6379
7      - NODE_ENV=production

Method 2: Using a .env file

Create a .env file in the same directory as your docker-compose.yml:

bash
1# .env file
2POSTGRES_DB=myapp
3POSTGRES_USER=appuser
4POSTGRES_PASSWORD=supersecret123
5API_PORT=3000

Then reference those variables in your Compose file:

yaml
1services:
2  database:
3    image: postgres:16-alpine
4    environment:
5      POSTGRES_DB: ${POSTGRES_DB}
6      POSTGRES_USER: ${POSTGRES_USER}
7      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
8    ports:
9      - "${API_PORT:-3000}:5432"

Method 3: env_file directive

yaml
1services:
2  api:
3    image: myapi:latest
4    env_file:
5      - ./config/api.env
6      - ./config/db.env
Security: Never commit .env files with real passwords to version control. Add .env to your .gitignore and provide a .env.example file with placeholder values instead.

depends_on and Service Startup Order

The depends_on directive controls the order in which services start. However, it only waits for the container to start, not for the service inside to be ready.

yaml
1services:
2  api:
3    build: ./api
4    depends_on:
5      - database
6      - cache
7
8  database:
9    image: postgres:16-alpine
10
11  cache:
12    image: redis:7-alpine

In this example, Docker Compose will start database and cache before api. But the database might not be ready to accept connections yet when the API tries to connect.

To solve this, use depends_on with health checks (covered in the next section):

yaml
1services:
2  api:
3    build: ./api
4    depends_on:
5      database:
6        condition: service_healthy
7      cache:
8        condition: service_started
9
10  database:
11    image: postgres:16-alpine
12    healthcheck:
13      test: ["CMD-SHELL", "pg_isready -U appuser"]
14      interval: 5s
15      timeout: 5s
16      retries: 5
17
18  cache:
19    image: redis:7-alpine

Now the API will wait until the database health check passes before starting.

Health Checks in Compose

Health checks tell Docker how to test whether a service is working correctly. This is essential for reliable service startup ordering and for production deployments.

yaml
1services:
2  web:
3    image: nginx:alpine
4    healthcheck:
5      test: ["CMD", "curl", "-f", "http://localhost"]
6      interval: 30s
7      timeout: 10s
8      retries: 3
9      start_period: 10s
10
11  database:
12    image: postgres:16-alpine
13    environment:
14      POSTGRES_PASSWORD: secret123
15    healthcheck:
16      test: ["CMD-SHELL", "pg_isready -U postgres"]
17      interval: 10s
18      timeout: 5s
19      retries: 5
20      start_period: 30s
21
22  cache:
23    image: redis:7-alpine
24    healthcheck:
25      test: ["CMD", "redis-cli", "ping"]
26      interval: 10s
27      timeout: 5s
28      retries: 3
29
30  api:
31    build: ./api
32    depends_on:
33      database:
34        condition: service_healthy
35      cache:
36        condition: service_healthy
37    healthcheck:
38      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
39      interval: 15s
40      timeout: 5s
41      retries: 3
42      start_period: 15s

Health check parameters explained:

  • test - The command to run to check health
  • interval - Time between checks (default 30s)
  • timeout - Maximum time to wait for a check (default 30s)
  • retries - Number of consecutive failures needed to mark as unhealthy (default 3)
  • start_period - Grace period for the container to initialize (default 0s)
Tip: You can check the health status of your services with docker compose ps. The status column will show healthy, unhealthy, or starting.

Practical Example: WordPress + MySQL + phpMyAdmin

Let us build a complete stack with WordPress, MySQL, and phpMyAdmin. This is a real-world example that demonstrates all the concepts we have covered.

Create a new directory and the Compose file:

bash
1# Create project directory
2mkdir wordpress-stack && cd wordpress-stack
3
4# Create the compose file
5touch docker-compose.yml
6
7# Create the .env file
8touch .env

First, create the .env file with your configuration:

bash
1# .env
2MYSQL_ROOT_PASSWORD=rootpass123
3MYSQL_DATABASE=wordpress
4MYSQL_USER=wpuser
5MYSQL_PASSWORD=wppass123
6WORDPRESS_PORT=8080
7PMA_PORT=8081

Now create the docker-compose.yml:

yaml
1# docker-compose.yml - WordPress + MySQL + phpMyAdmin
2
3services:
4  # MySQL Database
5  database:
6    image: mysql:8.0
7    restart: unless-stopped
8    environment:
9      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
10      MYSQL_DATABASE: ${MYSQL_DATABASE}
11      MYSQL_USER: ${MYSQL_USER}
12      MYSQL_PASSWORD: ${MYSQL_PASSWORD}
13    volumes:
14      - mysql-data:/var/lib/mysql
15    networks:
16      - wp-network
17    healthcheck:
18      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
19      interval: 10s
20      timeout: 5s
21      retries: 5
22      start_period: 30s
23
24  # WordPress Application
25  wordpress:
26    image: wordpress:6-apache
27    restart: unless-stopped
28    depends_on:
29      database:
30        condition: service_healthy
31    ports:
32      - "${WORDPRESS_PORT:-8080}:80"
33    environment:
34      WORDPRESS_DB_HOST: database:3306
35      WORDPRESS_DB_NAME: ${MYSQL_DATABASE}
36      WORDPRESS_DB_USER: ${MYSQL_USER}
37      WORDPRESS_DB_PASSWORD: ${MYSQL_PASSWORD}
38    volumes:
39      - wp-content:/var/www/html/wp-content
40    networks:
41      - wp-network
42    healthcheck:
43      test: ["CMD", "curl", "-f", "http://localhost:80"]
44      interval: 30s
45      timeout: 10s
46      retries: 3
47      start_period: 30s
48
49  # phpMyAdmin - Database Management UI
50  phpmyadmin:
51    image: phpmyadmin:5
52    restart: unless-stopped
53    depends_on:
54      database:
55        condition: service_healthy
56    ports:
57      - "${PMA_PORT:-8081}:80"
58    environment:
59      PMA_HOST: database
60      PMA_PORT: 3306
61      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
62    networks:
63      - wp-network
64
65volumes:
66  mysql-data:
67    name: wordpress-mysql-data
68  wp-content:
69    name: wordpress-content
70
71networks:
72  wp-network:
73    name: wordpress-network
74    driver: bridge

Now let us start the entire stack:

bash
1# Start all services in detached mode
2docker compose up -d
3
4# Watch the logs to see startup progress
5docker compose logs -f
6
7# Check the status of all services
8docker compose ps
9
10# Once everything is healthy, access:
11# WordPress: http://localhost:8080
12# phpMyAdmin: http://localhost:8081
13
14# To stop everything (keeping data)
15docker compose down
16
17# To stop everything AND delete all data
18docker compose down -v
How it works: MySQL starts first. WordPress waits for MySQL's health check to pass before starting. phpMyAdmin also waits for MySQL. WordPress connects to MySQL using the service name database as the hostname (Docker's built-in DNS resolves this automatically). All three services share the wp-network so they can communicate.

Common Patterns and Best Practices

Here are some patterns you will use frequently with Docker Compose:

Override files for different environments

Use a base file and override files for different environments:

yaml
1# docker-compose.yml (base)
2services:
3  api:
4    build: ./api
5    environment:
6      - NODE_ENV=production
7
8# docker-compose.override.yml (auto-loaded in development)
9services:
10  api:
11    volumes:
12      - ./api/src:/app/src  # Hot reload
13    environment:
14      - NODE_ENV=development
15      - DEBUG=true
16    ports:
17      - "9229:9229"  # Debug port
bash
1# Development (auto-loads override)
2docker compose up -d
3
4# Production (only base file)
5docker compose -f docker-compose.yml up -d
6
7# Explicit multiple files
8docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

Wait-for scripts

For applications that need more sophisticated startup checks:

yaml
1services:
2  api:
3    build: ./api
4    command: ["./wait-for-it.sh", "database:5432", "--", "node", "server.js"]
5    depends_on:
6      - database

Profiles for optional services

yaml
1services:
2  api:
3    image: myapi:latest
4
5  database:
6    image: postgres:16-alpine
7
8  # Only starts when explicitly requested
9  debug-tools:
10    image: busybox
11    profiles:
12      - debug
13
14  monitoring:
15    image: grafana/grafana
16    profiles:
17      - monitoring
bash
1# Start only default services
2docker compose up -d
3
4# Start default services + monitoring
5docker compose --profile monitoring up -d

Summary and Next Steps

In this article, you learned how to use Docker Compose to orchestrate multi-container applications. Here is what we covered:

  • Docker Compose basics: What it is and why you need it
  • YAML structure: Services, networks, and volumes
  • Essential commands: up, down, logs, ps, exec, and more
  • Environment variables: Inline, .env files, and env_file
  • depends_on: Controlling service startup order
  • Health checks: Ensuring services are truly ready
  • Practical example: WordPress + MySQL + phpMyAdmin stack
  • Best practices: Override files, profiles, and common patterns

For more details, check the official Docker Compose documentation: https://docs.docker.com/compose/

Next up: In Part 7, we will learn about Multi-Stage Builds to optimize your Docker images and reduce their size dramatically. See you there!
Share:
CV

Cristhian Villegas

Software Engineer specializing in Java, Spring Boot, Angular & AWS. Building scalable distributed systems with clean architecture.

Comments

Sign in to leave a comment

No comments yet. Be the first!

Related Articles