Multi-container Applications with Docker Compose

Understanding, designing, and implementing complex application architectures

Beyond Single Containers

Modern applications rarely run as single, monolithic services. Instead, they're composed of multiple specialized components that work together. Docker Compose excels at managing these complex, interconnected systems.

The City Analogy

Think of a multi-container application like a well-designed city:

  • Individual containers are like specialized buildings (residential, commercial, utility)
  • Networks are like road systems connecting different neighborhoods
  • Volumes are like storage facilities where important items are kept
  • Docker Compose is like city planning—coordinating how everything works together
  • Environment variables are like zoning regulations that determine how buildings function

Just as a city functions because its various components are designed to interact efficiently, a multi-container application works through carefully orchestrated interactions between specialized services.

Evolution of Application Architecture

flowchart LR subgraph "Monolithic Architecture" A[Single Application\nHandles Everything] end subgraph "Microservices Architecture" B1[Authentication Service] B2[User Service] B3[Product Service] B4[Payment Service] B5[Notification Service] B1 --- B2 B2 --- B3 B3 --- B4 B4 --- B5 end A --> B1 style A fill:#f9d6d6,stroke:#d32f2f style B1 fill:#e3f2fd,stroke:#1565c0 style B2 fill:#e3f2fd,stroke:#1565c0 style B3 fill:#e3f2fd,stroke:#1565c0 style B4 fill:#e3f2fd,stroke:#1565c0 style B5 fill:#e3f2fd,stroke:#1565c0

Multi-container Architecture Patterns

Before implementing multi-container applications, it's important to understand common architectural patterns that have proven effective in real-world scenarios.

Common Architecture Patterns

API Gateway Pattern

flowchart TD Client[Client] --> Gateway[API Gateway Container] Gateway --> ServiceA[Service A Container] Gateway --> ServiceB[Service B Container] Gateway --> ServiceC[Service C Container]

A single entry point routes requests to appropriate services

Backend for Frontend (BFF) Pattern

flowchart TD WebClient[Web Client] --> WebBFF[Web BFF Container] MobileClient[Mobile Client] --> MobileBFF[Mobile BFF Container] WebBFF --> ServiceA[Service Container] WebBFF --> ServiceB[Service Container] MobileBFF --> ServiceA MobileBFF --> ServiceB

Specialized backends for different client types

Sidecar Pattern

flowchart LR subgraph "Container Pod 1" A[Main Application] --- B[Sidecar Container\nLogging/Monitoring] end subgraph "Container Pod 2" C[Main Application] --- D[Sidecar Container\nLogging/Monitoring] end

Helper containers augment main application containers

Database-Per-Service Pattern

flowchart TD ServiceA[Service A Container] --> DbA[(Database A Container)] ServiceB[Service B Container] --> DbB[(Database B Container)] ServiceC[Service C Container] --> DbC[(Database C Container)]

Each service manages its own database

Choosing the Right Architecture

When designing a multi-container application, consider these factors:

Service Communication Patterns

Containers in a multi-container application need to communicate with each other. Docker Compose provides several mechanisms to facilitate this communication.

Container Communication Patterns Service Discovery Service Name Resolution http://servicename:port Automatic DNS resolution within the same Docker network Message Passing Message Brokers RabbitMQ, Kafka, Redis Asynchronous communication between services Shared Data Database Containers MongoDB, MySQL, Redis Persistent data accessible to multiple services API Communication RESTful or GraphQL APIs HTTP/HTTPS requests Direct service-to-service communication

Service Discovery

In Docker Compose, service discovery is built-in and straightforward. Services can communicate with each other using their service names as hostnames.

version: '3.8'

services:
  api:
    build: ./api
    # API service configuration...
    
  database:
    image: postgres:14
    # Database configuration...

  # The API service can connect to the database using:
  # DATABASE_URL=postgres://username:password@database:5432/dbname

RESTful API Communication

The most common pattern for service-to-service communication is RESTful APIs:

// In user-service container
const response = await fetch('http://order-service:3000/api/orders?userId=123');
const orders = await response.json();

Message Queue Communication

For asynchronous communication, message brokers like RabbitMQ are ideal:

version: '3.8'

services:
  publisher:
    build: ./publisher
    # Publisher service configuration...
    
  consumer:
    build: ./consumer
    # Consumer service configuration...
    
  rabbitmq:
    image: rabbitmq:3-management
    ports:
      - "5672:5672"  # AMQP port
      - "15672:15672"  # Management UI

Real-world Example: E-commerce Microservices

Let's examine a more complex real-world example: an e-commerce application built with microservices.

flowchart TD Client[Client Applications] --> Gateway[API Gateway] Gateway --> Auth[Auth Service] Gateway --> Catalog[Product Catalog] Gateway --> Cart[Shopping Cart] Gateway --> Order[Order Service] Gateway --> Payment[Payment Service] Gateway --> Notification[Notification Service] Auth --> AuthDB[(Auth DB)] Catalog --> ProductDB[(Product DB)] Cart --> CartDB[(Cart DB)] Order --> OrderDB[(Order DB)] Order --> Payment Payment --> Order Order --> Notification subgraph "Data Services" Search[Search Service] Analytics[Analytics Service] end Catalog --> Search Order --> Analytics

Docker Compose Configuration for E-commerce

version: '3.8'

services:
  # Frontend services
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf
      - ./frontend/dist:/usr/share/nginx/html
    depends_on:
      - api-gateway

  # API Gateway
  api-gateway:
    build: ./api-gateway
    ports:
      - "8000:8000"
    environment:
      AUTH_SERVICE_URL: http://auth-service:3001
      CATALOG_SERVICE_URL: http://catalog-service:3002
      CART_SERVICE_URL: http://cart-service:3003
      ORDER_SERVICE_URL: http://order-service:3004
      PAYMENT_SERVICE_URL: http://payment-service:3005
    depends_on:
      - auth-service
      - catalog-service
      - cart-service
      - order-service
      - payment-service

  # Core services
  auth-service:
    build: ./auth-service
    environment:
      DB_HOST: auth-db
      DB_PORT: 5432
      DB_NAME: authdb
      DB_USER: postgres
      DB_PASSWORD: password
    depends_on:
      - auth-db

  catalog-service:
    build: ./catalog-service
    environment:
      DB_HOST: product-db
      DB_PORT: 27017
      DB_NAME: products
    volumes:
      - ./catalog-service:/app
      - /app/node_modules
    depends_on:
      - product-db
      - search-service

  cart-service:
    build: ./cart-service
    environment:
      REDIS_HOST: cart-db
      REDIS_PORT: 6379
    depends_on:
      - cart-db

  order-service:
    build: ./order-service
    environment:
      DB_HOST: order-db
      DB_PORT: 5432
      DB_NAME: orders
      DB_USER: postgres
      DB_PASSWORD: password
      RABBITMQ_HOST: rabbitmq
    depends_on:
      - order-db
      - rabbitmq

  payment-service:
    build: ./payment-service
    environment:
      STRIPE_API_KEY: ${STRIPE_API_KEY}
      RABBITMQ_HOST: rabbitmq
    depends_on:
      - rabbitmq

  notification-service:
    build: ./notification-service
    environment:
      SMTP_HOST: smtp.example.com
      SMTP_PORT: 587
      SMTP_USER: ${SMTP_USER}
      SMTP_PASS: ${SMTP_PASS}
      RABBITMQ_HOST: rabbitmq
    depends_on:
      - rabbitmq

  # Data services
  search-service:
    image: elasticsearch:7.17.0
    environment:
      - discovery.type=single-node
      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
    volumes:
      - elasticsearch-data:/usr/share/elasticsearch/data

  analytics-service:
    build: ./analytics-service
    environment:
      DB_HOST: analytics-db
      DB_PORT: 27017
      DB_NAME: analytics
    depends_on:
      - analytics-db
      - rabbitmq

  # Databases
  auth-db:
    image: postgres:14
    environment:
      POSTGRES_DB: authdb
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
    volumes:
      - auth-db-data:/var/lib/postgresql/data

  product-db:
    image: mongo:6
    volumes:
      - product-db-data:/data/db

  cart-db:
    image: redis:alpine
    volumes:
      - cart-db-data:/data

  order-db:
    image: postgres:14
    environment:
      POSTGRES_DB: orders
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
    volumes:
      - order-db-data:/var/lib/postgresql/data

  analytics-db:
    image: mongo:6
    volumes:
      - analytics-db-data:/data/db

  # Message broker
  rabbitmq:
    image: rabbitmq:3-management
    ports:
      - "15672:15672"
    volumes:
      - rabbitmq-data:/var/lib/rabbitmq

volumes:
  auth-db-data:
  product-db-data:
  cart-db-data:
  order-db-data:
  analytics-db-data:
  elasticsearch-data:
  rabbitmq-data:

networks:
  default:
    driver: bridge

Understanding the E-commerce Architecture

This example demonstrates several important concepts:

Common Multi-container Patterns and Services

The API + Database Pattern

The most common multi-container pattern combines an application with a database:

version: '3.8'

services:
  api:
    build: ./api
    ports:
      - "3000:3000"
    environment:
      DB_HOST: db
      DB_PORT: 5432
      DB_USER: postgres
      DB_PASSWORD: password
      DB_NAME: myapp
    depends_on:
      - db

  db:
    image: postgres:14
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
      POSTGRES_DB: myapp
    volumes:
      - postgres-data:/var/lib/postgresql/data

volumes:
  postgres-data:

The Frontend + Backend + Database Pattern

Full-stack applications typically include a frontend service:

version: '3.8'

services:
  frontend:
    build: ./frontend
    ports:
      - "80:80"
    depends_on:
      - backend

  backend:
    build: ./backend
    ports:
      - "3000:3000"
    environment:
      DB_HOST: db
      # other DB environment variables...
    depends_on:
      - db

  db:
    image: postgres:14
    # DB configuration...

Adding Caching with Redis

Redis is commonly used for caching in multi-container applications:

version: '3.8'

services:
  api:
    build: ./api
    environment:
      REDIS_HOST: redis
      REDIS_PORT: 6379
    depends_on:
      - redis
      - db

  redis:
    image: redis:alpine
    ports:
      - "6379:6379"
    volumes:
      - redis-data:/data

  db:
    # Database configuration...

volumes:
  redis-data:

Including a Reverse Proxy

Nginx is often used as a reverse proxy and static file server:

version: '3.8'

services:
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf
      - ./nginx/ssl:/etc/nginx/ssl
    depends_on:
      - api

  api:
    build: ./api
    # API configuration not exposed directly to the outside world

  db:
    # Database configuration...

Scaling Services in Docker Compose

Docker Compose allows you to run multiple instances of a service, which is useful for improving performance and reliability.

Using docker-compose scale

# Scale the API service to 3 instances
docker-compose up -d --scale api=3

Load Balancing with Nginx

When scaling services, you'll need a load balancer to distribute traffic. Here's an example using Nginx:

# docker-compose.yml
version: '3.8'

services:
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf
    depends_on:
      - api

  api:
    build: ./api
    expose:
      - "3000"
    # other configuration...

# nginx/default.conf
upstream api_servers {
    server api:3000;
    # Docker Compose will automatically create api_1, api_2, etc.
    # when you scale the service
}

server {
    listen 80;

    location / {
        proxy_pass http://api_servers;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

Scaling Considerations

Deployment Considerations

While Docker Compose is excellent for development, there are considerations for deploying multi-container applications to production.

Development vs. Production

You can use different compose files for different environments:

my-app/
├── docker-compose.yml          # Base configuration
├── docker-compose.override.yml # Development overrides (loaded automatically)
└── docker-compose.prod.yml     # Production overrides

Using Multiple Compose Files

# Development (uses docker-compose.yml + docker-compose.override.yml)
docker-compose up -d

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

Production-specific Overrides Example

# docker-compose.prod.yml
version: '3.8'

services:
  api:
    image: ${DOCKER_REGISTRY}/my-app-api:${TAG}
    build: null  # Remove build instruction
    restart: always
    environment:
      NODE_ENV: production
    # Remove development-specific volumes
    volumes: []

  db:
    restart: always
    environment:
      POSTGRES_PASSWORD: ${DB_PASSWORD}  # Use environment variables
    volumes:
      - /var/data/postgres:/var/lib/postgresql/data  # Use host paths

Orchestration Alternatives

For production environments with multiple hosts, consider:

Monitoring and Debugging Multi-container Applications

As applications grow more complex with multiple containers, monitoring and debugging become crucial.

Logging Strategies

Docker Compose makes it easy to view logs from all services:

# View all logs
docker-compose logs

# Follow logs from specific services
docker-compose logs -f api db

# Tail the last 100 lines
docker-compose logs --tail=100

Centralized Logging

For more sophisticated logging, you can include dedicated logging services:

version: '3.8'

services:
  api:
    # Regular service config...
    logging:
      driver: "json-file"
      options:
        max-size: "200k"
        max-file: "10"
        
  # ELK Stack for log management
  elasticsearch:
    image: elasticsearch:7.17.0
    environment:
      - discovery.type=single-node
      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
    volumes:
      - elasticsearch-data:/usr/share/elasticsearch/data
        
  logstash:
    image: logstash:7.17.0
    volumes:
      - ./logstash/pipeline:/usr/share/logstash/pipeline
    depends_on:
      - elasticsearch
        
  kibana:
    image: kibana:7.17.0
    ports:
      - "5601:5601"
    environment:
      ELASTICSEARCH_HOSTS: http://elasticsearch:9200
    depends_on:
      - elasticsearch

Health Checks

Docker Compose supports health checks to ensure services are truly ready:

services:
  api:
    build: ./api
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 20s
    depends_on:
      db:
        condition: service_healthy
        
  db:
    image: postgres:14
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5

Advanced Docker Compose Techniques

Using YAML Anchors and Extensions

YAML anchors help reduce duplication in your Compose files:

version: '3.8'

x-common-variables: &common-variables
  POSTGRES_USER: postgres
  POSTGRES_PASSWORD: password
  LOG_LEVEL: info

services:
  service1:
    image: my-service1
    environment:
      <<: *common-variables
      SERVICE_NAME: service1
      
  service2:
    image: my-service2
    environment:
      <<: *common-variables
      SERVICE_NAME: service2

Using Profiles

Profiles allow you to selectively enable services for different scenarios:

version: '3.8'

services:
  app:
    image: myapp
    # Always started
  
  db:
    image: postgres
    # Always started
  
  selenium:
    image: selenium/standalone-chrome
    profiles:
      - test
  
  db-admin:
    image: adminer
    profiles:
      - debug
      - dev

Then you can start only the services for a specific profile:

# Start only the basic services plus testing tools
docker-compose --profile test up

# Start development tools
docker-compose --profile dev up

BuildKit Features

Enable BuildKit for advanced build features:

# Enable BuildKit
export DOCKER_BUILDKIT=1

# docker-compose.yml
services:
  api:
    build:
      context: ./api
      dockerfile: Dockerfile
      args:
        - API_VERSION=2.0
      cache_from:
        - myregistry/myapp:latest
      target: development  # For multi-stage builds

Hands-on Exercises

Exercise 1: Build a Three-tier Web Application

Create a Docker Compose file for a three-tier web application:

  1. Frontend (Nginx serving static files)
  2. Backend API (Node.js Express)
  3. Database (MongoDB)
  4. Include volume for database persistence
  5. Configure appropriate environment variables for each service
  6. Set up proper dependencies between services

Exercise 2: Implement a Microservices Architecture

Build a small microservices application with Docker Compose:

  1. Create three separate microservices (User, Product, Order)
  2. Implement an API gateway to route requests
  3. Add separate databases for each service
  4. Implement service-to-service communication
  5. Add shared authentication via Redis or JWT

Exercise 3: Create Development and Production Configurations

For an existing Docker Compose application:

  1. Create a base docker-compose.yml file
  2. Add a docker-compose.override.yml with development-specific settings
  3. Create a docker-compose.prod.yml with production configurations
  4. Use environment variables for secrets
  5. Implement proper volume management for both environments

Additional Resources

Summary and Next Steps

Multi-container applications allow for more sophisticated, scalable, and maintainable architectures. Key takeaways include:

In the next lecture, we'll explore Docker Compose networking and volumes in depth, which are crucial for proper data persistence and inter-service communication.