๐๏ธ 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
--scaleand 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.
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.
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.
๐ก 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: 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:
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.
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_KEYcomes 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.
- The checkout page needs the current price of an item to display it.
- After an order is placed, a confirmation email should be sent.
- 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.yamlentry reachable by name --scalereplicates stateless services; a reverse proxy spreads the load- More services means more power and more operational cost โ decompose deliberately
๐ Additional Resources
- Docker Docs โ Working with multiple Compose files
- Docker Docs โ Compose services reference
- Docker Docs โ Control startup and shutdown order
- Awesome Compose โ multi-service example projects
๐ 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.