Part 2 of 7

    Docker Compose for Multi-Container Apps

    Define your entire application stack in a single YAML file. One command brings everything up, another tears it down.

    Multi-Container
    YAML Config
    Service Discovery

    Most real-world applications aren't single containers. A typical web app needs a web server, a database, maybe a cache layer, and a reverse proxy. Managing these separately with docker run commands gets tedious fast—you'd need to remember port mappings, volume mounts, environment variables, and startup order for each container.

    1

    What is Docker Compose?

    Docker Compose is a tool for defining and running multi-container applications. You describe your services, networks, and volumes in a docker-compose.yml file, then use simple commands to manage the entire stack.

    Why use Compose over individual docker commands?

    • Reproducibility: Your entire stack is defined in code, easy to version control and share
    • Simplicity: docker compose up replaces dozens of docker run commands
    • Networking: Services automatically discover each other by name
    • Dependencies: Define startup order and health checks
    • Environment management: Keep development and production configs separate

    If you followed Part 1 and installed Docker using the official repository, Docker Compose is already installed as a plugin. Verify with:

    Verify installation
    docker compose version
    2

    Compose File Basics

    A Compose file is written in YAML and typically named docker-compose.yml. Here's the basic structure:

    docker-compose.yml structure
    services:
      service-name:
        image: image-name:tag
        ports:
          - "host:container"
        volumes:
          - volume-name:/path/in/container
        environment:
          - VARIABLE=value
    
    volumes:
      volume-name:

    Services

    Services are the containers that make up your application. Each service definition specifies which image to use, port mappings, volume mounts, environment variables, resource limits, and dependencies.

    Simple service example
    services:
      web:
        image: nginx:1.25
        ports:
          - "80:80"

    Volumes

    Named volumes persist data beyond the container lifecycle. Define them at the top level and reference them in services:

    Volume configuration
    services:
      database:
        image: postgres:16
        volumes:
          - db-data:/var/lib/postgresql/data
    
    volumes:
      db-data:

    Networks

    Compose creates a default network automatically. You can also define custom networks for isolation:

    Custom networks for isolation
    services:
      web:
        networks:
          - frontend
      api:
        networks:
          - frontend
          - backend
      database:
        networks:
          - backend
    
    networks:
      frontend:
      backend:

    In this example, web can talk to api, and api can talk to database, but web cannot directly reach database.

    3

    Essential Compose Options

    Image vs Build

    You can use pre-built images or build from a Dockerfile:

    Image vs build
    services:
      # Using a pre-built image
      nginx:
        image: nginx:1.25
    
      # Building from a Dockerfile
      app:
        build:
          context: ./app
          dockerfile: Dockerfile

    Port Mapping

    Port mapping options
    services:
      web:
        ports:
          - "80:80"           # host:container
          - "443:443"
          - "127.0.0.1:8080:80"   # Only accessible from localhost

    Volume Mounts

    Volume mount types
    services:
      web:
        volumes:
          # Named volume (managed by Docker)
          - app-data:/var/www/html
          
          # Bind mount (host directory)
          - ./config:/etc/nginx/conf.d:ro
          
          # Anonymous volume (not persisted after removal)
          - /var/cache/nginx
    
    volumes:
      app-data:

    The :ro suffix makes the mount read-only inside the container.

    Environment Variables

    Three ways to set environment variables:

    Environment variable methods
    services:
      app:
        # Method 1: Inline
        environment:
          - DATABASE_HOST=db
          - DATABASE_PORT=5432
        
        # Method 2: Key-value format
        environment:
          DATABASE_HOST: db
          DATABASE_PORT: 5432
        
        # Method 3: From a file
        env_file:
          - .env

    Important: Add .env to your .gitignore to avoid committing secrets.

    Restart Policies

    Restart policy
    services:
      web:
        restart: unless-stopped   # Recommended for production

    Options: no (default), always, on-failure, unless-stopped

    Dependencies and Health Checks

    Health check with dependency
    services:
      db:
        image: postgres:16
        healthcheck:
          test: ["CMD-SHELL", "pg_isready -U postgres"]
          interval: 5s
          timeout: 5s
          retries: 5
      
      web:
        depends_on:
          db:
            condition: service_healthy

    This ensures web waits until db is actually ready, not just started.

    Resource Limits

    Prevent a single container from consuming all your VPS resources:

    Resource limits
    services:
      app:
        deploy:
          resources:
            limits:
              cpus: '0.5'      # Half a CPU core
              memory: 512M
            reservations:
              cpus: '0.25'
              memory: 256M
    4

    Compose Commands

    Starting and Stopping

    Start and stop commands
    # Start all services (detached)
    docker compose up -d
    
    # Start specific services
    docker compose up -d web db
    
    # Stop all services
    docker compose down
    
    # Stop and remove volumes (destroys data!)
    docker compose down -v

    Viewing Status and Logs

    Status and logs
    # List running services
    docker compose ps
    
    # View logs
    docker compose logs
    
    # Follow logs for specific service
    docker compose logs -f web
    
    # Last 100 lines
    docker compose logs --tail 100 web

    Managing Services

    Service management
    # Restart a service
    docker compose restart web
    
    # Stop a service without removing
    docker compose stop web
    
    # Start a stopped service
    docker compose start web
    
    # Rebuild and restart (after code changes)
    docker compose up -d --build
    
    # Pull latest images
    docker compose pull

    Executing Commands

    Execute commands in containers
    # Run a command in a running container
    docker compose exec db psql -U postgres
    
    # Run a one-off command in a new container
    docker compose run --rm web npm install
    5

    Practical Example: WordPress with MariaDB

    Let's deploy a complete WordPress site with a database. This demonstrates multi-container orchestration in a real-world scenario.

    Create a project directory:

    Create project directory
    mkdir ~/wordpress && cd ~/wordpress

    Create docker-compose.yml:

    docker-compose.yml
    services:
      wordpress:
        image: wordpress:6.4-php8.2-apache
        restart: unless-stopped
        ports:
          - "80:80"
        environment:
          WORDPRESS_DB_HOST: db
          WORDPRESS_DB_USER: wordpress
          WORDPRESS_DB_PASSWORD: ${DB_PASSWORD}
          WORDPRESS_DB_NAME: wordpress
        volumes:
          - wordpress-data:/var/www/html
        depends_on:
          db:
            condition: service_healthy
    
      db:
        image: mariadb:11
        restart: unless-stopped
        environment:
          MYSQL_DATABASE: wordpress
          MYSQL_USER: wordpress
          MYSQL_PASSWORD: ${DB_PASSWORD}
          MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
        volumes:
          - db-data:/var/lib/mysql
        healthcheck:
          test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
          interval: 10s
          timeout: 5s
          retries: 3
          start_period: 30s
    
    volumes:
      wordpress-data:
      db-data:

    Create .env with your passwords:

    .env
    DB_PASSWORD=your-secure-password-here
    DB_ROOT_PASSWORD=your-root-password-here

    Start the stack:

    Start WordPress
    docker compose up -d

    What's Happening Here

    • Service discovery: WordPress connects to the database using db as the hostname
    • Health checks: WordPress waits until MariaDB reports healthy before starting
    • Persistent storage: Both WordPress files and database data survive restarts
    • Environment variables: Passwords are loaded from .env
    • Restart policy: Both services restart automatically after crashes or reboots

    Managing Your WordPress Stack

    Management commands
    # Backup the database
    docker compose exec db mariadb-dump -u root -p wordpress > backup.sql
    
    # Update WordPress
    docker compose pull
    docker compose up -d
    
    # View resource usage
    docker stats
    6

    Development Stack with Hot Reload

    Here's a Node.js development environment with MongoDB and Redis:

    Development stack
    services:
      app:
        build: .
        ports:
          - "3000:3000"
        volumes:
          - .:/app
          - /app/node_modules    # Exclude node_modules from bind mount
        environment:
          - NODE_ENV=development
          - MONGODB_URI=mongodb://mongo:27017/myapp
          - REDIS_URL=redis://redis:6379
        depends_on:
          - mongo
          - redis
        command: npm run dev
    
      mongo:
        image: mongo:7
        volumes:
          - mongo-data:/data/db
        ports:
          - "127.0.0.1:27017:27017"   # Only accessible locally
    
      redis:
        image: redis:7-alpine
        volumes:
          - redis-data:/data
    
    volumes:
      mongo-data:
      redis-data:

    The bind mount (- .:/app) syncs your local code into the container, enabling hot reload during development.

    7

    Reverse Proxy with Multiple Apps

    Running multiple web applications on one VPS? Use Nginx as a reverse proxy:

    Reverse proxy setup
    services:
      nginx:
        image: nginx:1.25-alpine
        ports:
          - "80:80"
          - "443:443"
        volumes:
          - ./nginx.conf:/etc/nginx/nginx.conf:ro
          - ./certs:/etc/nginx/certs:ro
        depends_on:
          - app1
          - app2
        restart: unless-stopped
    
      app1:
        image: your-app-1:latest
        expose:
          - "3000"
        restart: unless-stopped
    
      app2:
        image: your-app-2:latest
        expose:
          - "3000"
        restart: unless-stopped

    Note: expose makes the port available to other containers but doesn't publish it to the host. Only Nginx is accessible from outside.

    A basic nginx.conf for this setup:

    nginx.conf
    events {
        worker_connections 1024;
    }
    
    http {
        upstream app1 {
            server app1:3000;
        }
        
        upstream app2 {
            server app2:3000;
        }
    
        server {
            listen 80;
            server_name app1.yourdomain.com;
            
            location / {
                proxy_pass http://app1;
                proxy_set_header Host $host;
                proxy_set_header X-Real-IP $remote_addr;
            }
        }
    
        server {
            listen 80;
            server_name app2.yourdomain.com;
            
            location / {
                proxy_pass http://app2;
                proxy_set_header Host $host;
                proxy_set_header X-Real-IP $remote_addr;
            }
        }
    }
    8

    Managing Secrets Securely

    For production, avoid putting passwords directly in .env files that might accidentally get committed.

    Docker secrets (Swarm mode):

    Docker secrets
    services:
      db:
        image: postgres:16
        secrets:
          - db_password
        environment:
          POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    
    secrets:
      db_password:
        file: ./secrets/db_password.txt

    Environment variables from the host:

    Host environment variables
    services:
      app:
        environment:
          - API_KEY   # Reads from host's $API_KEY

    Set the variable before running:

    Set and run
    export API_KEY=your-key
    docker compose up -d
    9

    Compose File Organization

    As your stack grows, keep things maintainable:

    Use multiple Compose files for environments:

    File structure
    # Base configuration
    docker-compose.yml
    
    # Development overrides
    docker-compose.override.yml    # Automatically loaded
    
    # Production overrides
    docker-compose.prod.yml
    
    # Start with production config
    docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

    Example override for development:

    docker-compose.override.yml
    services:
      app:
        build: .
        volumes:
          - .:/app
        environment:
          - DEBUG=true

    Example production overrides:

    docker-compose.prod.yml
    services:
      app:
        image: your-registry/app:latest
        deploy:
          resources:
            limits:
              memory: 1G
        environment:
          - DEBUG=false
    10

    Resource Considerations

    Running multiple containers on a VPS requires planning:

    StackMinimum RAMRecommended
    WordPress + MariaDB1GB2GB
    Node app + MongoDB + Redis2GB4GB
    3-4 services + reverse proxy2GB4GB
    Full monitoring stack4GB8GB

    Tips for smaller VPS instances:

    • Use Alpine-based images when available (e.g., nginx:alpine, redis:alpine)
    • Set memory limits to prevent runaway containers
    • Disable development features in production (debug modes, verbose logging)
    • Consider using SQLite instead of a full database for simple apps
    11

    Troubleshooting

    Container keeps restarting:

    Check logs
    docker compose logs service-name

    Check for configuration errors, missing environment variables, or dependency issues.

    Service can't connect to database:

    • Verify both services are on the same network
    • Check the hostname matches the service name
    • Ensure the database is healthy before the app starts (use depends_on with health checks)

    Changes not taking effect:

    Force recreate
    docker compose up -d --force-recreate

    Port conflict:

    Find port usage
    # Find what's using the port
    sudo lsof -i :80

    Volume permissions:

    If a container can't write to a volume, check if the container runs as a non-root user:

    Fix permissions
    docker compose exec service-name chown -R user:group /path

    What's Next

    You now have the tools to deploy complex multi-container applications with Docker Compose. In Part 3, we'll cover production hardening: resource monitoring, logging strategies, automated updates, backups, and security best practices to run Docker reliably on your VPS.