In the previous tutorial, we learned how to build a Docker image for a single application. But most real applications need more than one service. A web app might need a database. An API might need a cache. A backend might need a message queue.
Running each service with docker run and connecting them manually is tedious. Docker Compose solves this. It lets you define all your services in one file and start everything with a single command.
What Is Docker Compose?
Docker Compose is a tool that comes with Docker Desktop. It reads a YAML file called docker-compose.yml (or compose.yml) and creates all the containers, networks, and volumes defined in it.
Instead of running multiple docker run commands with long flags, you write a configuration file once and use docker compose up.
The docker-compose.yml File
Create a new folder called my-project:
mkdir my-project
cd my-project
Create a file called docker-compose.yml:
services:
app:
image: nginx:alpine
ports:
- "8080:80"
This is the simplest Compose file. It defines one service called app that uses the nginx:alpine image and maps port 8080 to port 80.
Start it:
docker compose up
Open http://localhost:8080 in your browser. You should see the Nginx welcome page.
Press Ctrl+C to stop.
Understanding the Structure
A docker-compose.yml file has a clear structure. Here is a more complete example:
services:
app:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgres://alex:secret@db:5432/mydb
depends_on:
- db
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: alex
POSTGRES_PASSWORD: secret
POSTGRES_DB: mydb
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
Let’s break this down.
Services
Each service is a container. In this example, we have two services:
app— our applicationdb— a PostgreSQL database
build vs image
# Build from a Dockerfile in the current directory
app:
build: .
# Use a pre-built image from Docker Hub
db:
image: postgres:16-alpine
Use build when you have your own application with a Dockerfile. Use image when you want a pre-built image.
ports
ports:
- "3000:3000"
Maps port 3000 on your computer to port 3000 in the container. The format is host:container.
environment
You can set environment variables in two ways:
# List format
environment:
- DATABASE_URL=postgres://alex:secret@db:5432/mydb
- NODE_ENV=production
# Map format
environment:
POSTGRES_USER: alex
POSTGRES_PASSWORD: secret
Both formats work. Pick one and be consistent.
depends_on
app:
depends_on:
- db
This tells Docker Compose to start db before app. It controls startup order. However, it does not wait for the database to be ready — it only waits for the container to start. We will discuss this later.
volumes
db:
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
This creates a named volume called pgdata. The database stores its data in /var/lib/postgresql/data. Even if the container is removed, the data stays in the volume. We cover volumes in detail in Docker Tutorial #4.
Practical Example: App + PostgreSQL
Let’s build a real example. We will create a Python app that connects to a PostgreSQL database.
Create this folder structure:
my-project/
app.py
requirements.txt
Dockerfile
docker-compose.yml
app.py:
import os
import time
import psycopg2
from flask import Flask, jsonify
app = Flask(__name__)
def get_db_connection():
return psycopg2.connect(os.environ["DATABASE_URL"])
def init_db():
conn = get_db_connection()
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS visits (
id SERIAL PRIMARY KEY,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.commit()
cur.close()
conn.close()
@app.route("/")
def index():
conn = get_db_connection()
cur = conn.cursor()
cur.execute("INSERT INTO visits DEFAULT VALUES")
cur.execute("SELECT COUNT(*) FROM visits")
count = cur.fetchone()[0]
conn.commit()
cur.close()
conn.close()
return jsonify({"message": "Hello from Docker!", "visits": count})
if __name__ == "__main__":
time.sleep(2) # Wait for database to be ready
init_db()
app.run(host="0.0.0.0", port=5000)
requirements.txt:
flask==3.1.0
psycopg2-binary==2.9.10
Dockerfile:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 5000
CMD ["python", "app.py"]
docker-compose.yml:
services:
app:
build: .
ports:
- "5000:5000"
environment:
- DATABASE_URL=postgres://alex:secret@db:5432/mydb
depends_on:
- db
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: alex
POSTGRES_PASSWORD: secret
POSTGRES_DB: mydb
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
Start everything:
docker compose up --build
The --build flag tells Compose to build the app image before starting. Open http://localhost:5000 in your browser. Each time you refresh, the visit count increases. The count persists because the database uses a volume.
Essential Compose Commands
Start Services
# Start all services (attached — shows logs)
docker compose up
# Start in the background (detached)
docker compose up -d
# Start and rebuild images
docker compose up --build
# Start and rebuild in background
docker compose up -d --build
Stop Services
# Stop and remove containers
docker compose down
# Stop and remove containers + volumes (deletes data!)
docker compose down -v
# Stop without removing
docker compose stop
View Logs
# Logs for all services
docker compose logs
# Follow logs in real-time
docker compose logs -f
# Logs for one service only
docker compose logs -f app
Check Status
# List running services
docker compose ps
# Show running processes in each service
docker compose top
Execute Commands in a Running Service
# Open a shell in the app container
docker compose exec app bash
# Run a one-off command
docker compose exec db psql -U alex -d mydb
Rebuild
When you change your Dockerfile or source code:
# Rebuild and restart
docker compose up --build
# Only rebuild without starting
docker compose build
Using .env Files
Hardcoding passwords in docker-compose.yml is not ideal. You can use a .env file instead.
Create a .env file:
POSTGRES_USER=alex
POSTGRES_PASSWORD=secret
POSTGRES_DB=mydb
Update docker-compose.yml:
services:
app:
build: .
ports:
- "5000:5000"
environment:
- DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
depends_on:
- db
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
Docker Compose automatically reads the .env file in the same directory. The ${VARIABLE} syntax substitutes the values.
Add .env to your .gitignore and .dockerignore so secrets do not end up in version control or your image.
The depends_on Limitation
The depends_on option controls startup order. When you say app depends_on db, Compose starts db first. But it only waits for the container to start, not for the service inside to be ready.
A PostgreSQL container might take a few seconds to initialize. If your app tries to connect immediately, it might fail.
There are several ways to handle this:
Option 1: Retry in your application code. This is the most reliable approach. Add a retry loop that waits for the database.
Option 2: Use a wait script. Tools like wait-for-it or dockerize wait for a TCP port to be available before starting your app. You wrap your CMD with the wait script.
Option 3: Use depends_on with a health check. This is the cleanest approach.
services:
app:
build: .
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: alex
POSTGRES_PASSWORD: secret
POSTGRES_DB: mydb
healthcheck:
test: ["CMD-SHELL", "pg_isready -U alex"]
interval: 5s
timeout: 5s
retries: 5
With condition: service_healthy, Compose waits until the database passes its health check before starting the app.
Scaling Services
Docker Compose can run multiple copies of a service:
docker compose up -d --scale app=3
This starts 3 instances of the app service. All three share the same network and can connect to the database. This is useful for testing load balancing or running workers.
To see all running instances:
docker compose ps
Note: if you use ports: - "3000:3000", scaling fails because multiple containers cannot bind to the same host port. For scaling, use a reverse proxy like Nginx to distribute traffic.
Running One-Off Commands
Sometimes you need to run a command once — like running database migrations or creating test data. Use docker compose run:
# Run a command in the app service
docker compose run app python manage.py migrate
# Run a command and remove the container after
docker compose run --rm app python seed.py
The difference between exec and run:
exec— runs a command in an already running containerrun— creates a new container, runs the command, and stops
This is useful for database tasks:
# Connect to the database shell
docker compose exec db psql -U alex -d mydb
# Run a SQL file
docker compose exec -T db psql -U alex -d mydb < seed.sql
# Create a database backup
docker compose exec db pg_dump -U alex mydb > backup.sql
Overriding Configuration
You can create a docker-compose.override.yml file for local development settings. Compose merges it automatically with docker-compose.yml:
docker-compose.override.yml:
services:
app:
volumes:
- ./src:/app/src # Bind mount for live code reloading
environment:
- DEBUG=true
db:
ports:
- "5432:5432" # Expose DB port for local GUI tools
This keeps your base docker-compose.yml clean for production. The override file adds development-specific settings like bind mounts and exposed ports.
Add docker-compose.override.yml to .gitignore if each developer has different settings. Or commit it if the team shares the same development configuration.
Compose File Versions
You might see version: "3.8" at the top of older Compose files. This is no longer required. Modern versions of Docker Compose (v2+) do not need the version field. You can safely omit it:
# Modern — no version field needed
services:
app:
image: nginx:alpine
If you see a Compose file with a version field, it still works. Docker Compose simply ignores it.
Viewing Service Resource Usage
To see how much CPU and memory your services are using:
docker stats
This shows live resource usage for all running containers. Press Ctrl+C to stop. It helps you identify if a service is using too much memory or CPU.
For a one-time snapshot instead of a live view:
docker stats --no-stream
Common Mistakes
1. Forgetting --build after code changes. If you change your source code and run docker compose up, Compose uses the old image. Always use docker compose up --build when you change code or the Dockerfile.
2. Using docker compose down -v accidentally. The -v flag removes volumes. If your database stores data in a volume, this deletes all your data. Use docker compose down (without -v) unless you specifically want to delete volumes.
3. Hardcoding secrets in docker-compose.yml. Use .env files for passwords and API keys. Add .env to .gitignore so secrets stay out of your repository.
What We Learned
In this tutorial, you learned:
- Docker Compose manages multi-container applications with one file
docker-compose.ymldefines services, ports, environment variables, and volumesdocker compose upstarts all services,docker compose downstops themdepends_oncontrols startup order but does not wait for service readiness.envfiles keep secrets out of your configurationdocker compose logsanddocker compose exechelp with debugging
Related Articles
- Docker Tutorial #1: What is Docker — containers, images, and registries
- Docker Tutorial #2: Dockerfile — building custom images
- Docker Cheat Sheet — quick reference for all Docker commands
What’s Next?
In the next tutorial, we will take a deeper look at volumes and networking. You will learn the difference between named volumes and bind mounts, how containers communicate over networks, and how to persist data properly.