Docker Compose: Complete Guide to Multi-Container Applications

Tested on: Ubuntu 26.04 LTS · Debian 12 · Arch Linux — Last updated: June 2026
Docker Compose lets you define and run multi-container applications from a single YAML file. Instead of maintaining a wall of docker run commands with fragile flag lists, you describe your entire stack — web server, database, cache, queue, reverse proxy — in one compose.yaml and start everything with docker compose up. This guide covers the complete Compose workflow: file structure, networking, volumes, environment variables, secrets, health checks, profiles, real-world stack examples, and production hardening.
Prerequisites
- Docker Engine 24+ installed and running (
docker infoshould succeed withoutsudo) - Docker Compose v2 — included with Docker Desktop and the official
docker-compose-pluginpackage - Basic familiarity with Docker concepts (images, containers, volumes)
Install and Verify Docker Compose
Compose v2 ships as a Docker CLI plugin. If you installed Docker from the official repository, it is almost certainly already present. The command is docker compose — note the space, not a hyphen. The old standalone docker-compose (v1, Python) is end-of-life and should not be used for new work.
# Verify Compose v2 is available:
docker compose version
# Docker Compose version v2.27.1
# Not installed? Add the plugin on Debian/Ubuntu:
sudo apt update && sudo apt install docker-compose-plugin
# On Arch Linux:
sudo pacman -S docker-compose
# Confirm your user can run Docker without sudo:
# (add yourself to the docker group if needed)
sudo usermod -aG docker $USER
newgrp dockerYour First Compose File
Create a project directory and a file named compose.yaml. The legacy name docker-compose.yml still works, but compose.yaml is the current standard. Every Compose file has at minimum a services block. The following example runs WordPress backed by MariaDB — two containers, one command to start.
services:
db:
image: mariadb:11
restart: unless-stopped
environment:
MARIADB_ROOT_PASSWORD: rootpassword
MARIADB_DATABASE: wordpress
MARIADB_USER: wpuser
MARIADB_PASSWORD: wppassword
volumes:
- db_data:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
wordpress:
image: wordpress:6.5
restart: unless-stopped
depends_on:
db:
condition: service_healthy
ports:
- "8080:80"
environment:
WORDPRESS_DB_HOST: db
WORDPRESS_DB_NAME: wordpress
WORDPRESS_DB_USER: wpuser
WORDPRESS_DB_PASSWORD: wppassword
volumes:
- wp_content:/var/www/html/wp-content
volumes:
db_data:
wp_content:# Start all services in the background:
docker compose up -d
# Follow logs from all services:
docker compose logs -f
# Visit http://localhost:8080 for the WordPress setup wizardNotice depends_on with condition: service_healthy — this makes WordPress wait until MariaDB has passed its health check before starting, avoiding connection errors on first boot. Without a health check, depends_on only waits for the container process to start, not for the service inside it to be ready.
Essential Commands
# Start all services (detached):
docker compose up -d
# Start and force image rebuild:
docker compose up -d --build
# Stop all services (containers removed, volumes kept):
docker compose down
# Stop and remove named volumes — DESTRUCTIVE, deletes data:
docker compose down -v
# View running service status:
docker compose ps
# Follow logs (all services):
docker compose logs -f
# Follow logs for one service, last 100 lines:
docker compose logs -f --tail=100 db
# Restart a single service:
docker compose restart wordpress
# Pull latest versions of all images:
docker compose pull
# Execute a command inside a running container:
docker compose exec db mariadb -u root -prootpassword
docker compose exec wordpress bash
# Run a one-off command in a new container (doesn't start service deps):
docker compose run --rm wordpress wp --info
# Scale a stateless service to 3 replicas:
docker compose up -d --scale wordpress=3
# View resource usage:
docker compose statsService Configuration In Depth
Most production complexity lives in the service definition. The example below documents the options you will reach for most often.
services:
api:
# Use a pre-built image:
image: myorg/myapi:1.4.2
# Or build from a local Dockerfile:
build:
context: ./api
dockerfile: Dockerfile.prod
args:
BUILD_ENV: production
target: runtime # multi-stage build target
container_name: myapp-api # fixed name; disables scaling
restart: unless-stopped # always | on-failure | no | unless-stopped
# Publish host:container port:
ports:
- "127.0.0.1:8000:8000" # bind to loopback only — safer in production
# Expose port to other containers only (no host binding):
expose:
- "8000"
# Override the image CMD:
command: ["gunicorn", "app:app", "--workers=4", "--bind=0.0.0.0:8000"]
# Environment variables (inline — not for secrets):
environment:
APP_ENV: production
LOG_LEVEL: info
# Resource limits (Compose standalone and Swarm mode):
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
reservations:
cpus: "0.25"
memory: 128M
labels:
- "traefik.enable=true"
- "traefik.http.routers.api.rule=Host(`api.example.com`)"Networking
Compose creates a dedicated bridge network for your project automatically. Every service joins it and can reach any other service using the service name as the hostname. No port publishing is needed for container-to-container communication — only for traffic coming from the host or external clients.
For more complex stacks, define multiple networks to enforce isolation. A database should never be reachable from a frontend container that has no business talking to it.
services:
frontend:
image: nginx:1.26
ports:
- "80:80"
networks:
- public
api:
image: myapi:latest
networks:
- public
- internal
db:
image: postgres:16
networks:
- internal # db is unreachable from frontend
networks:
public:
internal:
internal: true # blocks all traffic in/out of this network from the host# Containers reach each other by service name:
# From the api container:
# psql -h db -U postgres
# curl http://frontend/health
# Inspect the networks Compose created:
docker network ls | grep myproject
docker network inspect myproject_internalVolumes and Data Persistence
Named volumes are managed by Docker and survive docker compose down. Bind mounts map a host path into a container — useful for injecting config files or mounting source code during development. Never use anonymous volumes (bare container paths with no name) for anything you care about keeping.
services:
db:
image: postgres:16
volumes:
# Named volume — Docker manages storage location:
- pg_data:/var/lib/postgresql/data
# Bind mount — inject an init script (read-only):
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
app:
build: .
volumes:
# Bind mount for live code reload in development:
- .:/app
# Anonymous volume to prevent overwriting node_modules:
- /app/node_modules
volumes:
pg_data:
# Use a pre-existing volume not managed by this Compose file:
shared_data:
external: true
# Custom driver options — e.g., bind to a specific host path with explicit options:
pg_data_fast:
driver: local
driver_opts:
type: none
o: bind
device: /mnt/nvme/pg_data# List volumes for this project:
docker volume ls | grep myproject
# Back up a named volume to a tar archive:
docker run --rm
-v myproject_pg_data:/data
-v $(pwd):/backup
alpine tar czf /backup/pg_data_backup.tar.gz -C /data .
# Restore:
docker run --rm
-v myproject_pg_data:/data
-v $(pwd):/backup
alpine tar xzf /backup/pg_data_backup.tar.gz -C /dataEnvironment Variables
Compose supports three patterns for environment variable injection. Use inline values only for non-sensitive config. Use .env files for local development. Use Docker secrets (below) for anything that should never appear in a file on disk in plaintext.
# .env file — auto-loaded by Compose from the project directory:
DB_HOST=db
DB_PORT=5432
DB_NAME=myapp
DB_PASSWORD=changeme
API_KEY=abc123def456services:
app:
image: myapp:latest
# Method 1: inline (fine for non-sensitive config):
environment:
NODE_ENV: production
PORT: "3000"
# Method 2: pull from .env with variable substitution:
environment:
- DB_HOST=${DB_HOST}
- DB_PORT=${DB_PORT:-5432} # default value if not set
- DB_PASSWORD=${DB_PASSWORD:?DB_PASSWORD must be set} # fail if missing
# Method 3: load an entire env file directly into the container:
env_file:
- .env
- .env.production # later file overrides earlier# Debug variable substitution — shows the resolved compose config:
docker compose config
# Check which .env file is being used:
docker compose config | grep DB_PASSWORDSecrets Management
Docker secrets mount sensitive values as files under /run/secrets/ inside the container. The secret never appears in environment variables, process lists, or docker inspect output. Applications read the file at runtime. Many official images support _FILE suffixed environment variables specifically for this pattern.
# Create secret files (not committed to version control):
mkdir -p secrets
echo "s3cr3tP@ssword" > secrets/db_password.txt
echo "sk-proj-abc123xyz" > secrets/openai_api_key.txt
chmod 600 secrets/*.txtservices:
db:
image: postgres:16
secrets:
- db_password
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
api:
image: myapi:latest
secrets:
- db_password
- openai_api_key
environment:
DB_PASSWORD_FILE: /run/secrets/db_password
OPENAI_API_KEY_FILE: /run/secrets/openai_api_key
secrets:
db_password:
file: ./secrets/db_password.txt
openai_api_key:
file: ./secrets/openai_api_key.txtProfiles
Profiles attach optional services to named groups. Services with no profile always start. Services with a profile only start when that profile is explicitly activated. This keeps development tools, database UIs, and CI-only containers out of your default docker compose up.
services:
app:
image: myapp:latest # always starts
db:
image: postgres:16 # always starts
adminer:
image: adminer:latest
profiles: ["dev"] # only with --profile dev
ports:
- "8081:8080"
depends_on:
- db
tests:
image: myapp:latest
profiles: ["ci"]
command: ["pytest", "-v", "--tb=short"]
depends_on:
- db# Default: only app + db start:
docker compose up -d
# Development: add adminer:
docker compose --profile dev up -d
# CI pipeline: run tests then exit:
docker compose --profile ci run --rm tests
# Multiple profiles simultaneously:
docker compose --profile dev --profile monitoring up -dReal-World Stack Examples
Ollama + Open WebUI (Local AI)
services:
ollama:
image: ollama/ollama:latest
container_name: ollama
restart: unless-stopped
volumes:
- ollama_models:/root/.ollama
ports:
- "127.0.0.1:11434:11434"
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
open-webui:
image: ghcr.io/open-webui/open-webui:main
container_name: open-webui
restart: unless-stopped
ports:
- "3000:8080"
environment:
- OLLAMA_BASE_URL=http://ollama:11434
- WEBUI_AUTH=true
volumes:
- open_webui_data:/app/backend/data
depends_on:
- ollama
volumes:
ollama_models:
open_webui_data:Prometheus + Grafana Monitoring
services:
prometheus:
image: prom/prometheus:v2.52.0
restart: unless-stopped
ports:
- "127.0.0.1:9090:9090"
