Docker Compose: Complete Guide to Multi-Container Applications

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.

Contents
  1. Prerequisites
  2. Install and Verify Docker Compose
  3. Your First Compose File
  4. Essential Commands
  5. Service Configuration In Depth
  6. Networking
  7. Volumes and Data Persistence
  8. Environment Variables
  9. Secrets Management
  10. Profiles
  11. Real-World Stack Examples
    1. Ollama + Open WebUI (Local AI)
    2. Prometheus + Grafana Monitoring
    3. Further Reading

Prerequisites

  • Docker Engine 24+ installed and running (docker info should succeed without sudo)
  • Docker Compose v2 — included with Docker Desktop and the official docker-compose-plugin package
  • 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 docker

Your 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 wizard

Notice 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 stats

Service 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_internal

Volumes 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 /data

Environment 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=abc123def456
services:
  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_PASSWORD

Secrets 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/*.txt
services:
  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.txt

Profiles

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 -d

Real-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"


Go up