Docker Compose for Multi-Container Applications: A Practical Guide

July 22, 2026 · 7 min read

If you've been running Docker containers with docker run one at a time, you're missing the real power of containerization. Real-world applications are rarely a single container — they're a web server, a database, a cache, a queue worker, and more. Docker Compose is the tool that ties them all together with a single YAML file.

This guide walks through a complete multi-container setup, from a basic web + database stack to production-ready patterns with health checks, networking, and environment management.

Why Not Just docker run?

Managing multiple containers manually is tedious. Each docker run call needs port mappings, volume mounts, network assignments, and environment variables. You end up with a shell script that's impossible to maintain:

# The manual approach — don't do this
docker network create myapp
docker volume create pgdata
docker run -d --name db --network myapp \
  -v pgdata:/var/lib/postgresql/data \
  -e POSTGRES_PASSWORD=secret \
  postgres:16
docker run -d --name redis --network myapp redis:7-alpine
docker run -d --name api --network myapp \
  -p 3000:3000 \
  -e DATABASE_URL=postgres://... \
  myapp-api

Docker Compose replaces all of that with a declarative YAML file that you can version-control, review, and share.

Your First Compose File

Here's a typical web application with a Python API, PostgreSQL, and Redis:

# docker-compose.yml
version: "3.9"

services:
  api:
    build: ./api
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgresql://app:secret@db:5432/appdb
      - REDIS_URL=redis://redis:6379/0
    depends_on:
      - db
      - redis

  db:
    image: postgres:16-alpine
    volumes:
      - pgdata:/var/lib/postgresql/data
    environment:
      - POSTGRES_USER=app
      - POSTGRES_PASSWORD=secret
      - POSTGRES_DB=appdb

  redis:
    image: redis:7-alpine

volumes:
  pgdata:

Run it with a single command:

docker compose up -d

Docker Compose automatically creates a network for all services, so they can reach each other by service name (db, redis). No manual network setup needed.

Key Concepts

Service Discovery

Compose creates a default network where each service is reachable by its name. Your API connects to db:5432 and redis:6379 — no need to look up IPs or manage /etc/hosts.

Volumes

Named volumes (like pgdata above) persist data across container restarts. Bind mounts are better for development with hot-reload:

services:
  api:
    build: ./api
    volumes:
      - ./api/src:/app/src  # bind mount for live code reload

Dependency Ordering

depends_on controls startup order but doesn't wait for the service to be ready. For that, you need health checks.

Health Checks and Startup Ordering

PostgreSQL takes a few seconds to start. If your API connects before the database is ready, it crashes. Solve this with health checks:

services:
  db:
    image: postgres:16-alpine
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
      interval: 5s
      timeout: 5s
      retries: 5

  api:
    build: ./api
    depends_on:
      db:
        condition: service_healthy
    ports:
      - "3000:3000"

Now Compose waits until pg_isready returns success before starting the API.

Networking: Custom Networks

For security, isolate your database on an internal network that's not exposed to the internet:

services:
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    networks:
      - frontend

  api:
    build: ./api
    networks:
      - frontend
      - backend

  db:
    image: postgres:16-alpine
    networks:
      - backend

networks:
  frontend:
  backend:
    internal: true  # no external access

Nginx can reach the API, but only the API can reach the database. The database has no route to the outside world.

Environment Management with .env

Never hardcode secrets in docker-compose.yml. Use a .env file:

# .env
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
DB_NAME=appdb
API_PORT=3000
# docker-compose.yml
services:
  db:
    image: postgres:16-alpine
    environment:
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
      - POSTGRES_DB=${DB_NAME}

Compose automatically reads .env from the project directory. For production, use Docker secrets or an external vault.

Profiles: Dev vs Production

Use profiles to include dev-only services like a database admin tool:

services:
  db:
    image: postgres:16-alpine
    # ...

  pgadmin:
    image: dpage/pgadmin4
    profiles:
      - dev
    ports:
      - "5050:80"
    environment:
      - PGADMIN_DEFAULT_EMAIL=dev@local
      - PGADMIN_DEFAULT_PASSWORD=dev
# Run with dev tools
docker compose --profile dev up -d

# Production — no pgadmin
docker compose up -d

Resource Limits

Prevent runaway containers from starving the host:

services:
  api:
    build: ./api
    deploy:
      resources:
        limits:
          cpus: "0.5"
          memory: 512M
        reservations:
          cpus: "0.25"
          memory: 256M

Real-World Pattern: Full Stack App

Here's a complete example combining everything:

# docker-compose.yml
version: "3.9"

x-logging: &default-logging
  driver: json-file
  options:
    max-size: "10m"
    max-file: "3"

services:
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - api
    networks:
      - frontend
    logging: *default-logging

  api:
    build:
      context: ./api
      target: production
    expose:
      - "3000"
    environment:
      - DATABASE_URL=postgresql://app:${DB_PASSWORD}@db:5432/appdb
      - REDIS_URL=redis://redis:6379/0
      - SECRET_KEY=${SECRET_KEY}
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    networks:
      - frontend
      - backend
    deploy:
      resources:
        limits:
          memory: 512M
    restart: unless-stopped
    logging: *default-logging

  worker:
    build:
      context: ./api
      target: production
    command: celery -A tasks worker --loglevel=info
    environment:
      - REDIS_URL=redis://redis:6379/0
    depends_on:
      - redis
    networks:
      - backend
    deploy:
      resources:
        limits:
          memory: 256M
    logging: *default-logging

  db:
    image: postgres:16-alpine
    volumes:
      - pgdata:/var/lib/postgresql/data
    environment:
      - POSTGRES_USER=app
      - POSTGRES_PASSWORD=${DB_PASSWORD}
      - POSTGRES_DB=appdb
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
      interval: 5s
      timeout: 5s
      retries: 5
    networks:
      - backend
    restart: unless-stopped
    logging: *default-logging

  redis:
    image: redis:7-alpine
    volumes:
      - redisdata:/data
    networks:
      - backend
    restart: unless-stopped
    logging: *default-logging

volumes:
  pgdata:
  redisdata:

networks:
  frontend:
  backend:
    internal: true

Common Commands

CommandWhat it does
docker compose up -dStart all services in background
docker compose down -vStop and remove volumes
docker compose logs -f apiFollow logs for a specific service
docker compose exec api bashShell into a running container
docker compose psList running services and ports
docker compose build --no-cacheRebuild images from scratch

Pitfalls to Avoid

Summary

Docker Compose turns a fragile collection of shell scripts into a declarative, version-controlled infrastructure definition. Start with a simple web + database stack, then layer in health checks, custom networks, resource limits, and profiles as your application grows.

The same docker-compose.yml can run on your laptop, a CI runner, or a production server — making it one of the most practical tools in the DevOps toolbox.