Skip to main content

๐Ÿ—๏ธ Multi-Container Applications

Real applications are rarely one process. A production web app is a front end, an API, a database, usually a cache, and often a queue โ€” each a specialized service that does one job well. This lesson is about composing those pieces into a coherent whole: how they talk to each other, which architecture patterns are worth knowing, and what a genuinely non-trivial stack looks like.

Week 11 · Day 3 (Wednesday: Docker Compose) · Lecture 2

๐ŸŽฏ Learning Objectives

By the end of this lesson, you will be able to:

  • Explain why applications are split into multiple cooperating services
  • Recognize common patterns: API gateway, backend-for-frontend, and database-per-service
  • Choose between synchronous (HTTP) and asynchronous (message queue) service communication
  • Build a four-service stack โ€” web, API, database, and Redis cache โ€” in one compose.yaml
  • Read and reason about a realistic e-commerce microservices Compose file
  • Use --scale and a reverse proxy to run multiple instances of a service

Estimated Time: 65 minutes

Practice: Add a Redis cache to a web + API + database stack and confirm the API reaches it by name.

In This Lesson

Beyond Single Containers

In the last lesson you ran a three-service stack. That wasn't an accident of the example โ€” it reflects how modern software is actually built. Instead of one giant program that does everything, we split responsibilities across services that can be developed, deployed, scaled, and even rewritten independently.

๐Ÿ™๏ธ The city analogy

A multi-container application is like a well-planned city. Each container is a specialized building โ€” housing, a power plant, a warehouse. Networks are the roads connecting neighborhoods. Volumes are the storage depots that outlast any single building. Compose is the city planner, coordinating how it all fits together. A city works because its parts are designed to interact; so does your stack.

Monolith to services

The trade-off is real and worth naming honestly. A monolith is simpler to start and deploy. Splitting into services buys you independent scaling and fault isolation but adds operational complexity โ€” more moving parts, more network hops, more ways to misconfigure. Compose is what makes the multi-service approach practical on a single machine during development.

graph LR subgraph Monolith M["One app handles everything"] end subgraph Services S1[Auth] S2[Catalog] S3[Orders] S4[Payments] S1 --- S2 S2 --- S3 S3 --- S4 end M -->|"decompose"| S1

Architecture Patterns

You don't have to invent structure from scratch. A handful of patterns show up again and again; recognizing them helps you organize services sensibly.

API Gateway

A single entry point receives every client request and routes it to the right internal service. Clients only ever talk to the gateway, which also becomes a natural home for cross-cutting concerns like authentication, rate limiting, and TLS termination.

graph TD Client[Client] --> Gateway[API Gateway] Gateway --> A[Auth Service] Gateway --> B[Catalog Service] Gateway --> C[Order Service]

Backend for Frontend (BFF)

Different clients have different needs. A web app and a mobile app might want the same data shaped differently. A BFF gives each client type its own tailored backend, each talking to the shared internal services.

Database per Service

Each service owns its own database and no other service touches it directly. This keeps services loosely coupled and lets each pick the right storage โ€” Postgres for orders, MongoDB for a product catalog, Redis for a cart. The cost is that cross-service data consistency now requires deliberate coordination.

graph TD A[Order Service] --> DA[(Postgres)] B[Catalog Service] --> DB[(MongoDB)] C[Cart Service] --> DC[(Redis)]

๐Ÿ’ก Choosing a structure

Match the architecture to the problem, not the hype. Weigh application scale, team structure (independent teams pair well with independent services), how often parts change, and how important fault isolation is. When in doubt, start simpler โ€” a well-factored monolith or a small handful of services โ€” and split further only when a real pressure demands it.

How Services Communicate

Once you have multiple services, they need to talk. There are two broad styles, and mature systems use both.

Synchronous HTTP versus asynchronous message queue communication Synchronous ยท HTTP Service A Service B request, then wait for reply REST or GraphQL over HTTP Asynchronous ยท Queue Producer Broker Consumer fire and forget RabbitMQ, Kafka, Redis
Synchronous calls block until they get an answer; asynchronous messages decouple sender from receiver.

Synchronous: direct HTTP calls

The most common style. One service makes an HTTP request to another and waits for the response. Thanks to Compose's built-in DNS, the target is just the service name:

// Inside the "orders" service, calling the "users" service.
// "users" is the service name โ†’ Compose resolves it on the network.
const res = await fetch('http://users:3000/api/users/123');
const user = await res.json();

Simple and easy to reason about, but it couples the caller's availability to the callee's: if users is down, orders feels it immediately.

Asynchronous: message queues

For work that doesn't need an instant answer โ€” sending a confirmation email, generating a report, updating analytics โ€” a message broker decouples the services. The producer drops a message and moves on; a consumer processes it whenever it's ready. This adds resilience (the broker buffers work if a consumer is briefly down) at the cost of eventual, rather than immediate, consistency.

services:
  publisher:
    build: ./publisher
    depends_on:
      - rabbitmq

  consumer:
    build: ./consumer
    depends_on:
      - rabbitmq

  rabbitmq:
    image: rabbitmq:3-management
    ports:
      - "15672:15672"   # management UI at http://localhost:15672

A Four-Service Stack

Let's grow last lesson's three-tier app into the shape most real projects take: web, API, database, and a Redis cache. The cache sits between the API and the database, holding frequently read data so the API can skip a database round-trip on a hit.

# compose.yaml โ€” web + api + db + cache
services:
  web:
    image: nginx:alpine
    ports:
      - "8080:80"
    volumes:
      - ./web/dist:/usr/share/nginx/html:ro
    depends_on:
      - api

  api:
    build: ./api
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgres://app:secret@db:5432/appdb
      REDIS_URL: redis://cache:6379      # "cache" is the Redis service name
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started

  cache:
    image: redis:7-alpine
    volumes:
      - cache_data:/data

  db:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: appdb
    volumes:
      - db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  db_data:
  cache_data:
graph LR Browser --> Web[web: nginx] Web --> Api[api: node] Api -->|"cache hit or miss"| Cache[cache: redis] Api -->|"on miss"| Db[db: postgres] Db --- V1[(db_data)] Cache --- V2[(cache_data)]

Notice how naturally the fourth service slots in. The API reaches Redis at redis://cache:6379 โ€” again, just a service name. Each stateful service gets its own named volume. And the two databases start with different depends_on conditions: Postgres has a real readiness check, while Redis is fast enough that waiting for the container to start is enough.

โœ… The cache-aside pattern in one breath

On a read, the API asks Redis first. Hit? Return it. Miss? Query Postgres, store the result in Redis with a short expiry, then return it. Subsequent reads skip the database entirely. This is the single most common reason a Redis service appears in a stack.

Real Example: E-commerce

Here's a trimmed but realistic e-commerce stack. It uses the API-gateway and database-per-service patterns, mixes storage engines (polyglot persistence), and adds a message broker for asynchronous work like order notifications.

graph TD Client[Client Apps] --> Gateway[API Gateway] Gateway --> Auth[Auth Service] Gateway --> Catalog[Catalog Service] Gateway --> Orders[Order Service] Gateway --> Pay[Payment Service] Auth --> AuthDB[(Postgres)] Catalog --> ProdDB[(MongoDB)] Orders --> OrderDB[(Postgres)] Orders --> MQ[RabbitMQ] Pay --> MQ MQ --> Notify[Notification Service]
services:
  gateway:
    build: ./gateway
    ports:
      - "8000:8000"
    environment:
      AUTH_URL: http://auth:3001
      CATALOG_URL: http://catalog:3002
      ORDER_URL: http://orders:3003
      PAYMENT_URL: http://payment:3004
    depends_on:
      - auth
      - catalog
      - orders
      - payment

  auth:
    build: ./auth
    environment:
      DATABASE_URL: postgres://postgres:password@auth-db:5432/authdb
    depends_on:
      auth-db:
        condition: service_healthy

  catalog:
    build: ./catalog
    environment:
      MONGO_URL: mongodb://product-db:27017/products
    depends_on:
      - product-db

  orders:
    build: ./orders
    environment:
      DATABASE_URL: postgres://postgres:password@order-db:5432/orders
      RABBITMQ_URL: amqp://rabbitmq:5672
    depends_on:
      order-db:
        condition: service_healthy
      rabbitmq:
        condition: service_started

  payment:
    build: ./payment
    environment:
      STRIPE_API_KEY: ${STRIPE_API_KEY}    # from a .gitignored .env
      RABBITMQ_URL: amqp://rabbitmq:5672
    depends_on:
      - rabbitmq

  notification:
    build: ./notification
    environment:
      RABBITMQ_URL: amqp://rabbitmq:5672
    depends_on:
      - rabbitmq

  # --- Data stores (database per service) ---
  auth-db:
    image: postgres:16
    environment:
      POSTGRES_DB: authdb
      POSTGRES_PASSWORD: password
    volumes:
      - auth_db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5

  product-db:
    image: mongo:7
    volumes:
      - product_db_data:/data/db

  order-db:
    image: postgres:16
    environment:
      POSTGRES_DB: orders
      POSTGRES_PASSWORD: password
    volumes:
      - order_db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5

  rabbitmq:
    image: rabbitmq:3-management
    ports:
      - "15672:15672"
    volumes:
      - rabbitmq_data:/var/lib/rabbitmq

volumes:
  auth_db_data:
  product_db_data:
  order_db_data:
  rabbitmq_data:

This one file captures a lot of design at once:

  • API gateway as the single client entry point, holding the URLs of every internal service
  • Database per service โ€” auth and orders each own a Postgres instance, catalog owns MongoDB
  • Polyglot persistence โ€” the right storage engine for each job rather than one database forced everywhere
  • Asynchronous events โ€” orders and payments publish to RabbitMQ; the notification service consumes them
  • Secrets by substitution โ€” STRIPE_API_KEY comes from a .gitignored .env, never the committed YAML

โš ๏ธ Complexity is a cost, not a badge

A stack like this is powerful and also a lot to operate. Every service you add is another thing to monitor, secure, and debug. Reach for this level of decomposition when the app genuinely needs it โ€” not because microservices sound impressive.

Scaling a Service

Compose can run several copies of a single service, which is handy for load testing or squeezing more throughput out of a stateless API on one host.

# Run 3 instances of the api service
docker compose up -d --scale api=3

For this to help, the scaled service must be stateless โ€” it must not keep important data in its own memory, because each request might hit a different instance. Push shared state to Redis or the database. You'll also want a reverse proxy in front to spread traffic across the instances:

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

  api:
    build: ./api
    expose:
      - "3000"     # visible to other services, not published to the host
# nginx.conf โ€” Compose load-balances "api" across all scaled instances
upstream api_pool {
    server api:3000;
}
server {
    listen 80;
    location / {
        proxy_pass http://api_pool;
        proxy_set_header Host $host;
    }
}

๐Ÿ’ก Where Compose ends

Single-host scaling is Compose's ceiling. Spreading instances across many machines with health-based rescheduling and rolling deploys is orchestrator territory (Kubernetes, Swarm). Compose gets you surprisingly far first.

Practice & Quiz

๐Ÿ‹๏ธ Exercise 1: Add a cache

Goal: Starting from a web + API + Postgres stack, add a Redis cache service and give the API a REDIS_URL that reaches it by name.

๐Ÿ’ก Hint

Redis's default port is 6379. The connection URL takes the form redis://SERVICE_NAME:6379, where the service name is whatever key you give the Redis service under services:.

โœ… Solution
  api:
    build: ./api
    environment:
      DATABASE_URL: postgres://app:secret@db:5432/appdb
      REDIS_URL: redis://cache:6379
    depends_on:
      - db
      - cache

  cache:
    image: redis:7-alpine
    volumes:
      - cache_data:/data

volumes:
  db_data:
  cache_data:

Confirm connectivity from inside the API container with docker compose exec api sh, then check the cache directly via docker compose exec cache redis-cli ping (it should answer PONG).

๐Ÿ‹๏ธ Exercise 2: Pick a communication style

Goal: For each interaction, decide whether synchronous HTTP or an asynchronous queue fits better, and say why.

  1. The checkout page needs the current price of an item to display it.
  2. After an order is placed, a confirmation email should be sent.
  3. Every purchase should update a nightly analytics dashboard.
โœ… Solution
  • (1) Synchronous HTTP โ€” the user is waiting and needs the answer now.
  • (2) Asynchronous queue โ€” the email can be sent moments later; don't make checkout wait on the mail server.
  • (3) Asynchronous queue โ€” analytics tolerate delay, and decoupling keeps a slow analytics consumer from affecting checkout.

๐ŸŽฏ Quick Quiz

Question 1: In the API-gateway pattern, who do external clients talk to?

Question 2: Which task is the best fit for an asynchronous message queue?

Question 3: What must be true for --scale api=3 to work well?

Best Practices & Pitfalls

โœ… Do

  • Give each service one clear responsibility
  • Reach other services by service name, never a hard-coded IP
  • Use synchronous HTTP for "I need the answer now," queues for "this can happen later"
  • Give each stateful service its own named volume
  • Keep scaled services stateless; push shared state to Redis or a database
  • Start simpler and decompose further only when a real pressure demands it

โŒ Don't

  • Let two services write to the same database schema in the database-per-service pattern
  • Chain long synchronous call graphs โ€” one slow service stalls the whole request
  • Store session or cart state in the memory of a service you plan to scale
  • Add services for their own sake โ€” every one is more to operate and debug
  • Forget healthchecks on databases that other services depend on

โš ๏ธ The distributed-system tax

The moment a request crosses a network boundary, you inherit partial failures, retries, timeouts, and eventual consistency. These are solvable โ€” but they're real costs. A multi-service architecture should pay for itself in independent scaling or team autonomy, not just look modern.

Summary

๐ŸŽ‰ Key Takeaways

  • Real apps decompose into cooperating services โ€” web, API, database, cache, queue
  • Patterns like API gateway and database-per-service give that structure a name
  • Services talk synchronously (HTTP, need it now) or asynchronously (queue, can wait)
  • Adding a service is as easy as another compose.yaml entry reachable by name
  • --scale replicates stateless services; a reverse proxy spreads the load
  • More services means more power and more operational cost โ€” decompose deliberately

๐Ÿ“š Additional Resources

๐Ÿš€ What's Next?

You've been reaching services by name and persisting data with named volumes without fully unpacking how either works. The next lesson does exactly that: networking and volumes in depth โ€” custom networks for isolation, the difference between named volumes and bind mounts, and how to keep your data safe.

๐ŸŽ‰ Great progress!

You can now design a whole application as a set of cooperating containers โ€” the way production systems are actually built.