Maintenance

Site is under maintenance — quizzes are still available.

Go to quizzes

Multi-Service Stacks with Compose

Learn to orchestrate multi-service stacks with Docker Compose. This lesson covers core concepts, hands-on walkthroughs, troubleshooting, and next steps for progressive mastery.

Focus: orchestrate multi-service stacks with compose

Sponsored

You've mastered single-container Dockerfiles and maybe even a two-service docker-compose.yml. But real applications — an API backed by a database, a message queue, a cache, and a frontend — require you to orchestrate multi-service stacks with Compose as a single, repeatable unit. Without this skill, you'll spend your days manually starting containers in the right order, configuring networks by hand, and wrestling with environment variables across services. Docker Compose does all that for you — this lesson shows you how.

The problem this lesson solves

Running multiple containers by hand is error-prone, slow, and impossible to share with your team. Consider a typical web app stack: an Express API, a PostgreSQL database, and a Redis cache. You need to:

  • Start the database before the API (depends_on).
  • Connect containers on the same network so they can talk.
  • Pass environment variables like database URLs and secret keys without embedding them in images.
  • Scale the API to multiple replicas.
  • Expose only the API port to the outside world.

Without Compose, you'd write a shell script that chains docker run commands — brittle, hard to debug, and opaque to newcomers. With Compose, you declare the entire stack in a compose.yaml file, then docker compose up brings everything to life. This is orchestration at the developer level — not Kubernetes scale, but exactly the power you need for local development, CI, and small production deployments.

Core concept / mental model

Think of Docker Compose as a declarative blueprint for your multi-service application. Instead of giving Docker a series of step-by-step instructions (imperative), you tell it what the final state should look like: "Run an API container on port 3000, a Postgres container on port 5432, both on a shared network called backend." Docker Compose figures out the order and dependencies.

Definitions

  • Service: A container definition in Compose — corresponds to a docker run ... command. Each service has an image (or build context), ports, environment variables, volumes, etc.
  • Network: An isolated L2 segment connecting services by service name. No need to use IP addresses — just http://api:3000 from the frontend.
  • Volume: Persistent storage shared among containers or mounted from the host.
  • Profile: Conditional services you can enable with --profile profilename.

Diagram in words

┌──────────────────────────────────────┐
│            compose.yaml              │
│  services:                           │
│    api:  build: ./api  ports: 3000   │
│    db:   image: postgres:16 volumes  │
│    redis: image: redis:7-alpine      │
│  networks:                           │
│    backend:                           │
└───────────────────────────┬──────────┘
                            │
                            ▼
┌──────────────────────────────────────────┐
│  docker compose up  →  3 containers     │
│  + auto network + volumes               │
│  + start order                          │
└──────────────────────────────────────────┘

How it works step by step

  1. Define services in a compose.yaml file (or docker-compose.yaml). Each service gets a name, image or build context, ports, environment, volumes, and networks.
  2. Specify dependencies using depends_on. Compose will order startup accordingly, but does not wait for the service to be ready inside the container (that's your health check).
  3. Create networks automatically. By default, Compose creates one network per stack — all services can reach each other by service name.
  4. Mount volumes for persistence or config. Named volumes (e.g., db-data) survive container restarts. Bind mounts (e.g., ./src:/app) are great for development.
  5. Set environment variables in the file or from an .env file. Never hardcode secrets in images.
  6. Run the stack with docker compose up. Add -d to detach, --build to rebuild images.
  7. Scale services with docker compose up -d --scale api=3 — Compose creates three replicas of the API service.

Pro tip: Use depends_on only for startup order. For readiness, implement a health check (healthcheck section) in the service definition.

Hands-on walkthrough

Let's build a three-service stack: a Python Flask API, a PostgreSQL database, and a Redis cache. The API will increment a counter in Redis and store the logged requests in Postgres.

Step 1: Project structure

myapp/
├── compose.yaml
├── api/
│   ├── Dockerfile
│   ├── requirements.txt
│   └── app.py
└── .env

Step 2: compose.yaml

services:
  api:
    build: ./api
    ports:
      - "5000:5000"
    environment:
      DATABASE_URL: "postgresql://user:pass@db/mydb"
      REDIS_URL: "redis://redis:6379"
    depends_on:
      - db
      - redis
    networks:
      - backend

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
      POSTGRES_DB: mydb
    volumes:
      - pgdata:/var/lib/postgresql/data
    networks:
      - backend

  redis:
    image: redis:7-alpine
    networks:
      - backend

volumes:
  pgdata:

networks:
  backend:

Step 3: The API code (api/app.py)

from flask import Flask, jsonify
import redis
import psycopg2
import os

app = Flask(__name__)
r = redis.Redis.from_url(os.environ["REDIS_URL"])
db_conn = psycopg2.connect(os.environ["DATABASE_URL"])

@app.route("/")
def index():
    # Increment the request counter in Redis
    count = r.incr("hits")
    # Log to Postgres
    cur = db_conn.cursor()
    cur.execute("INSERT INTO hits (timestamp) VALUES (NOW())")
    db_conn.commit()
    cur.close()
    return jsonify({"message": f"Hit #{count} logged"})

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)

Step 4: Build and run

cd myapp
docker compose up --build

Expected output (trimmed):

[+] Building 2.3s (10/10) FINISHED
[+] Running 4/4
 ✔ Network myapp_backend   Created
 ✔ Volume myapp_pgdata     Created
 ✔ Container myapp-db-1    Started
 ✔ Container myapp-redis-1 Started
 ✔ Container myapp-api-1   Started

Now visit http://localhost:5000. Each request increments the counter — you've orchestrated a multi-service stack.

Compare options / when to choose what

Tool / Approach Use Case Complexity Overhead
Docker Compose Single-host multi-service dev & small prod Low None
Docker Swarm Multi-host orchestration with native Compose-like YAML Medium Built into Docker
Kubernetes (K8s) Multi-host, auto-scaling, complex microservices High Heavy
Plain docker run script One-off or two-container experiments Very low Manual

Compromise: Start with Compose. You can later convert the same YAML to a Stacks file for Swarm or use kompose to convert to K8s manifests.

Troubleshooting & edge cases

1. Container can't reach another service by hostname

Symptom: Name or service not known inside the container. Fix: Ensure both services are on the same network. Check compose.yaml — if you define custom networks, list them under both services. Use docker compose exec api ping db to test.

2. Depends_on not waiting for DB to be ready

Symptom: API starts before Postgres is ready to accept connections, causing connection errors. Fix: Use a wait script inside the API's Dockerfile or add a health check and use condition: service_healthy:

services:
  api:
    depends_on:
      db:
        condition: service_healthy
  db:
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user -d mydb"]
      interval: 5s
      timeout: 5s
      retries: 5

3. Ports already in use

Symptom: Error response from daemon: driver failed programming external connectivity on endpoint ... bind: address already in use. Fix: Change the host port mapping (e.g., "5001:5000") or stop the conflicting container (docker stop <container>).

4. Named volume persists old data

Symptom: Starting fresh stack still has old database records. Fix: Remove the volume with docker compose down -v. This destroys all named volumes, so use with care.

What you learned & what's next

You now understand how to orchestrate multi-service stacks with Compose: how to define services, networks, volumes, and dependencies in a single declarative file. You built a real stack — Flask + Postgres + Redis — and saw how services discover each other by name, start in the right order, and share persistent volumes.

Key takeaways for today: - Docker Compose replaces brittle shell scripts with a declarative blueprint. - Every service in the same default network can reach others by service name. - Use depends_on with health checks for reliable startup ordering. - Volumes keep data alive across container restarts.

Next lesson: We'll explore Docker Compose profiles — how to spin up optional services (like a test database or debug container) without bloating the main stack. That's a critical step toward production-grade Compose files.

Practice recap

Modify the hands-on stack: add a fourth service — a simple Nginx reverse proxy that ports 80 to the API's 5000. Use depends_on to start Nginx after the API. Run docker compose up --build and verify http://localhost returns the hit counter. This teaches service chaining and the power of multi-service orchestration.

Common mistakes

  • Forgetting to put both services on the same custom network — containers on different networks can't communicate by hostname.
  • Using depends_on without a health check, then wondering why the dependent service fails to connect to the database immediately.
  • Hardcoding environment variables (like database passwords) in compose.yaml instead of using an .env file — a security risk and hard to rotate.
  • Binding container ports to the host on every service, even internal ones like Postgres — you only need to expose the API port externally.

Variations

  1. Use docker compose profiles to conditionally start services — e.g., a test database for --profile test.
  2. Convert the same compose.yaml to a Docker Swarm Stack file by adding deploy sections — no need to rewrite everything.
  3. Use extensions (the x- prefix) to define reusable environment variables or health check blocks, keeping your YAML DRY.

Real-world use cases

  • Local development: run an API, database, and message queue in isolated, predictable containers with one command.
  • CI/CD pipelines: spin up dependent services (e.g., Postgres, Redis, Elasticsearch) for integration tests, then tear them down with docker compose down.
  • Small production deployments: deploy a stateless frontend (React/Angular) and a backend API behind a reverse proxy (Nginx) on a single VM using Compose.

Key takeaways

  • Docker Compose orchestrates multi-service stacks through a single declarative YAML file.
  • Services are isolated but can communicate via shared networks using service names as hostnames.
  • Use depends_on with health checks (condition: service_healthy) for reliable startup ordering.
  • Volumes persist data across container restarts; mount them with named volumes in the file.
  • Keep secrets and environment-specific values in an .env file, not in the compose file itself.
  • Docker Compose is the stepping stone to Swarm and Kubernetes — same mental model, more operators.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.