Container Orchestration with Docker Compose#

Introduction#

As applications grow in complexity, they often require multiple services working togetherβ€”a web application, a database, a cache layer, a message queue, and more. Managing these services individually with Docker commands becomes tedious and error-prone. Docker Compose solves this by allowing you to define and run multi-container applications with a single configuration file.

Why Docker Compose Matters for AI/RAG Projects:

  • Multi-service RAG stacks: Combine your API, vector database, Redis cache, and embedding service

  • Development parity: Replicate production environments locally

  • Consistent deployments: Version-controlled infrastructure as code

  • Easy scaling: Run multiple instances of services for testing


Docker Compose Basics#

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 Concepts:

  • Service: A container running from a specific image with defined configuration

  • Project: A collection of services defined in a compose file (typically named after the directory)

  • Network: Isolated network for inter-service communication

  • Volume: Persistent storage shared between containers or with the host

Compose File Structure#

# docker-compose.yml (Compose V2 format)
services:
  # Service definitions
  app:
    image: python:3.11-slim
    build: .
    ports:
      - "8000:8000"
    environment:
      - DEBUG=true
    depends_on:
      - database

  database:
    image: postgres:15
    volumes:
      - db_data:/var/lib/postgresql/data

# Named volumes
volumes:
  db_data:

# Custom networks (optional - default network is created automatically)
networks:
  backend:
    driver: bridge

Docker Compose V2 (the current standard) no longer requires the version key at the top of the file. The compose.yaml file now follows the open Compose Specification, which is supported by other tools beyond Docker (like Podman Compose).

Essential Docker Compose Commands#

# Start all services (detached mode)
docker compose up -d

# Start and rebuild images
docker compose up -d --build

# Stop all services
docker compose down

# Stop and remove volumes
docker compose down -v

# View running services
docker compose ps

# View logs for all services
docker compose logs

# Follow logs for specific service
docker compose logs -f app

# Execute command in running service
docker compose exec app bash

# Run one-off command
docker compose run --rm app python manage.py migrate

# Scale a service
docker compose up -d --scale worker=3

# View resource usage
docker compose top

Multi-service Architecture#

Typical Application Stack#

A modern application typically consists of multiple services working together:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                      Load Balancer                       β”‚
β”‚                     (nginx/traefik)                      β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                            β”‚
           β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
           β–Ό                β–Ό                β–Ό
    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚   App (1)   β”‚  β”‚   App (2)   β”‚  β”‚   App (3)   β”‚
    β”‚   FastAPI   β”‚  β”‚   FastAPI   β”‚  β”‚   FastAPI   β”‚
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
           β”‚                β”‚                β”‚
           β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                            β”‚
           β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
           β–Ό                β–Ό                β–Ό
    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚  PostgreSQL β”‚  β”‚    Redis    β”‚  β”‚  Qdrant/    β”‚
    β”‚  (Primary)  β”‚  β”‚   (Cache)   β”‚  β”‚  Chroma DB  β”‚
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Complete RAG Application Example#

# docker-compose.yml
services:
  # ===================
  # Application Service
  # ===================
  app:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: rag-api
    ports:
      - "8000:8000"
    environment:
      - DATABASE_URL=postgresql://postgres:postgres@postgres:5432/ragdb
      - REDIS_URL=redis://redis:6379/0
      - QDRANT_URL=http://qdrant:6333
      - OPENAI_API_KEY=${OPENAI_API_KEY}
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_started
      qdrant:
        condition: service_started
    volumes:
      - ./app:/app # Development: hot reload
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 30s
      timeout: 10s
      retries: 3

  # ===================
  # Database Service
  # ===================
  postgres:
    image: postgres:15-alpine
    container_name: rag-postgres
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
      - POSTGRES_DB=ragdb
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
    ports:
      - "5432:5432"
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5

  # ===================
  # Cache Service
  # ===================
  redis:
    image: redis:7-alpine
    container_name: rag-redis
    command: redis-server --appendonly yes
    volumes:
      - redis_data:/data
    ports:
      - "6379:6379"
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5

  # ===================
  # Vector Database
  # ===================
  qdrant:
    image: qdrant/qdrant:latest
    container_name: rag-qdrant
    volumes:
      - qdrant_data:/qdrant/storage
    ports:
      - "6333:6333"
      - "6334:6334" # gRPC
    restart: unless-stopped

# ===================
# Persistent Volumes
# ===================
volumes:
  postgres_data:
    driver: local
  redis_data:
    driver: local
  qdrant_data:
    driver: local

Service Dependencies#

Docker Compose supports dependency ordering with conditions:

services:
  app:
    depends_on:
      database:
        condition: service_healthy # Wait until healthy
      redis:
        condition: service_started # Just wait for start
      migrations:
        condition: service_completed_successfully # Wait for completion

Dependency Conditions:

Condition

Behavior

service_started

Default; waits for container to start

service_healthy

Waits for healthcheck to pass

service_completed_successfully

Waits for container to complete with exit code 0

depends_on conditions only control container startup order. They do not guarantee the application inside the container is fully ready to accept connections. Your application must implement its own retry/backoff logic for connecting to dependencies.

# Example: Retry logic for database connection
import time
from sqlalchemy import create_engine
from sqlalchemy.exc import OperationalError

def connect_with_retry(url, max_retries=5):
    for attempt in range(max_retries):
        try:
            engine = create_engine(url)
            engine.connect()
            return engine
        except OperationalError:
            time.sleep(2 ** attempt)  # Exponential backoff
    raise Exception("Could not connect to database")

Networking#

Default Network Behavior#

Docker Compose automatically creates a default network for your project. All services can communicate using their service names as hostnames.

services:
  app:
    # Can connect to postgres using hostname "postgres"
    environment:
      - DATABASE_URL=postgresql://user:pass@postgres:5432/db

  postgres:
    image: postgres:15
    # Accessible at hostname "postgres" within the network

Custom Networks#

For more complex architectures, define custom networks:

services:
  # Frontend services
  web:
    networks:
      - frontend
      - backend

  # API services
  api:
    networks:
      - backend
      - database

  # Database - isolated from frontend
  postgres:
    networks:
      - database

networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge
  database:
    driver: bridge
    internal: true # No external access

Exposing Ports#

services:
  app:
    ports:
      # HOST:CONTAINER
      - "8000:8000" # Expose on all interfaces
      - "127.0.0.1:8000:8000" # Expose only on localhost
      - "8001-8010:8001-8010" # Port range
    expose:
      - "9000" # Expose only to linked services, not host

Volumes and Data Persistence#

Volume Types#

services:
  app:
    volumes:
      # Named volume (managed by Docker)
      - app_data:/app/data

      # Bind mount (host directory)
      - ./src:/app/src

      # Bind mount with read-only flag
      - ./config:/app/config:ro

      # Anonymous volume (not recommended)
      - /app/temp

volumes:
  app_data:
    driver: local
    driver_opts:
      type: none
      o: bind
      device: /path/on/host

Data Initialization#

Initialize databases with SQL scripts:

services:
  postgres:
    image: postgres:15
    volumes:
      # Scripts in this directory run on first startup
      - ./init-scripts:/docker-entrypoint-initdb.d:ro
      - postgres_data:/var/lib/postgresql/data
-- init-scripts/01-schema.sql
CREATE TABLE IF NOT EXISTS documents (
    id SERIAL PRIMARY KEY,
    content TEXT NOT NULL,
    embedding VECTOR(384),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX ON documents USING ivfflat (embedding vector_cosine_ops);

Environment Management#

Environment Variables#

Method 1: Inline in compose file

services:
  app:
    environment:
      - DEBUG=true
      - LOG_LEVEL=info

Method 2: Environment file

services:
  app:
    env_file:
      - .env
      - .env.local # Overrides .env
# .env
DATABASE_URL=postgresql://postgres:postgres@postgres:5432/db
REDIS_URL=redis://redis:6379/0
DEBUG=false

Method 3: Variable substitution

services:
  app:
    image: myapp:${VERSION:-latest}
    environment:
      - API_KEY=${API_KEY} # Must be set in shell or .env

Secrets Management#

For sensitive data, use Docker secrets (Swarm mode) or mount secret files:

services:
  app:
    environment:
      - DATABASE_PASSWORD_FILE=/run/secrets/db_password
    secrets:
      - db_password
    volumes:
      - ./secrets/api_key.txt:/run/secrets/api_key:ro

secrets:
  db_password:
    file: ./secrets/db_password.txt

For production, avoid storing secrets in compose files or local files. Use:

  • Secrets managers: HashiCorp Vault, AWS Secrets Manager, GCP Secret Manager

  • Kubernetes secrets: When deploying to K8s

  • CI/CD injection: Inject at runtime via environment variables

The file-based approach above is suitable for local development only.

Multiple Environments#

Use override files for different environments:

# Base configuration
docker-compose.yml

# Development overrides
docker-compose.override.yml  # Automatically loaded

# Production overrides
docker-compose.prod.yml
# docker-compose.yml (base)
services:
  app:
    image: myapp:latest
    environment:
      - NODE_ENV=production

# docker-compose.override.yml (development - auto-loaded)
services:
  app:
    build: .
    volumes:
      - ./src:/app/src  # Hot reload
    environment:
      - NODE_ENV=development
      - DEBUG=true

# docker-compose.prod.yml (production - explicit)
services:
  app:
    deploy:
      replicas: 3
      resources:
        limits:
          cpus: '0.5'
          memory: 512M
# Development (uses docker-compose.yml + docker-compose.override.yml)
docker compose up

# Production
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

Health Checks and Restart Policies#

Health Checks#

services:
  app:
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 30s # How often to check
      timeout: 10s # Max time for check
      retries: 3 # Failures before unhealthy
      start_period: 40s # Grace period for startup

  postgres:
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5

Restart Policies#

services:
  app:
    restart: unless-stopped # Recommended for most services

  worker:
    restart: on-failure # Only restart on failure

  one-time-job:
    restart: "no" # Never restart

Policy

Behavior

no

Never restart (default)

always

Always restart

on-failure

Restart only on non-zero exit

unless-stopped

Always restart unless explicitly stopped


Development Workflow#

Hot Reload Setup#

services:
  app:
    build: .
    volumes:
      - ./app:/app # Mount source code
      - /app/__pycache__ # Exclude pycache (anonymous volume)
    command: uvicorn main:app --host 0.0.0.0 --reload
    environment:
      - PYTHONDONTWRITEBYTECODE=1

Running Migrations#

services:
  migrations:
    build: .
    command: alembic upgrade head
    environment:
      - DATABASE_URL=postgresql://postgres:postgres@postgres:5432/db
    depends_on:
      postgres:
        condition: service_healthy

  app:
    depends_on:
      migrations:
        condition: service_completed_successfully

Debugging#

services:
  app:
    build: .
    ports:
      - "8000:8000"
      - "5678:5678" # Debugger port
    command: python -m debugpy --listen 0.0.0.0:5678 -m uvicorn main:app --host 0.0.0.0

Live File Sync with Watch#

The watch command provides automatic file synchronization without bind mounts, offering better performance especially on macOS and Windows:

services:
  app:
    build: .
    ports:
      - "8000:8000"
    develop:
      watch:
        # Sync source code changes instantly
        - action: sync
          path: ./src
          target: /app/src
          ignore:
            - __pycache__/

        # Sync+restart on config changes
        - action: sync+restart
          path: ./config
          target: /app/config

        # Rebuild container on dependency changes
        - action: rebuild
          path: pyproject.toml
# Start with watch mode
docker compose watch

# Or run in background
docker compose up -d && docker compose watch

Watch Actions:

Action

Behavior

sync

Copies files into container without restart

sync+restart

Copies files and restarts the service

rebuild

Rebuilds and replaces the container

watch is faster than bind mounts on macOS/Windows because it uses efficient file transfer instead of volume mounting.


Production Considerations#

Resource Limits#

services:
  app:
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 1G
        reservations:
          cpus: "0.5"
          memory: 512M

The deploy section (including replicas, resources, restart_policy) is only fully applied with Docker Swarm (docker stack deploy).

When using standalone docker compose up, many deploy settings are ignored. For standalone Compose, use service-level settings:

services:
  app:
    # Standalone Compose resource limits (works without Swarm)
    cpus: "1.0"
    mem_limit: 1G
    mem_reservation: 512M

Logging Configuration#

services:
  app:
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

  # Or use external logging
  app-syslog:
    logging:
      driver: syslog
      options:
        syslog-address: "tcp://192.168.0.42:123"

Production Compose File#

# docker-compose.prod.yml
services:
  app:
    image: registry.example.com/myapp:${VERSION}
    deploy:
      replicas: 3
      resources:
        limits:
          cpus: "1.0"
          memory: 1G
      restart_policy:
        condition: on-failure
        delay: 5s
        max_attempts: 3
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
    logging:
      driver: json-file
      options:
        max-size: "50m"
        max-file: "5"

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./certs:/etc/nginx/certs:ro
    depends_on:
      - app

Compose Profiles#

Use profiles to selectively start services:

services:
  app:
    # Always starts (no profile)
    build: .

  postgres:
    # Always starts
    image: postgres:15

  redis:
    # Only with specific profile
    image: redis:7
    profiles:
      - cache

  debug-tools:
    image: alpine
    profiles:
      - debug
    command: sleep infinity
# Start only default services
docker compose up

# Start with cache profile
docker compose --profile cache up

# Start with multiple profiles
docker compose --profile cache --profile debug up

Summary#

Key Takeaways:

  1. Docker Compose Basics

    • Define multi-container apps in a single YAML file

    • Services communicate via service names as hostnames

    • Use docker compose up -d to start all services

  2. Service Configuration

    • Use depends_on with conditions for proper startup order

    • Configure health checks for reliable service discovery

    • Set appropriate restart policies for production

  3. Networking

    • Default network allows all services to communicate

    • Use custom networks to isolate service groups

    • Expose ports explicitly for external access

  4. Data Management

    • Use named volumes for persistent data

    • Use bind mounts for development (hot reload)

    • Initialize databases with scripts in entrypoint directories

  5. Environment Management

    • Use .env files for configuration

    • Use override files for different environments

    • Never commit secrets to version control

  6. Production Readiness

    • Set resource limits

    • Configure logging with rotation

    • Use health checks and restart policies

    • Run containers as non-root users (see Docker security docs)

    • Use secrets managers for sensitive configuration


References#

  1. Docker Compose Documentation

  2. Compose File Reference

  3. Compose Specification

  4. Compose Networking

  5. Compose in Production

  6. Docker Compose Best Practices

  7. 12-Factor App Methodology