LFCE 135 ๐ง Docker Compose โ Multi-Service Apps
A multi-service application is where Compose stops being a convenience wrapper for docker run and starts being the description of a system. The file defines not just the containers but the relationships between them: which service depends on which, which network carries which traffic, which volume holds which data, and which service is allowed to talk to the outside world. The complexity is not in any single service definition. It is in the connections.
This chapter covers the patterns that make a multi-service Compose application work: the three-tier web/database/cache stack, the healthcheck-gated startup order that prevents the web service from crashing before the database is ready, the network isolation that keeps the database off the public network, and the environment variable and volume patterns that separate configuration from the file. By the end, you will be able to read a Compose file and predict which services can reach which, which data survives a restart, and what happens when one service fails.
Key point: depends_on without a condition only waits for the container to start, not for the application inside it to be ready. A web service that depends on a database will start immediately, connect before Postgres has initialized, and crash. The condition: service_healthy with a healthcheck on the dependency is the fix.
Why multi-service composition is harder than it looks
The startup order problem. The Docker documentation states it directly: Compose does not wait until a container is “ready”, only until it is running. A database container that is running may still be initializing its data directory, applying migrations, or starting its listener. A web service that connects immediately after the container starts will fail, and depending on the restart policy, it may crash-loop until the database catches up . The condition: service_healthy option, combined with a healthcheck on the dependency, is the mechanism that waits for readiness.
The network exposure problem. In a three-tier application, only the web tier needs to be reachable from outside. The database and the cache should not be. A Compose file with a single default network puts every service on the same network, which means the database is reachable from any container in the stack and, if a port is published, from the host. Splitting the stack into a frontend network and a backend network isolates the database and cache from external traffic .
The configuration problem. A multi-service file accumulates environment variables: database credentials, cache hosts, API keys, feature flags. Hardcoding them in the YAML file puts secrets in version control and makes the file different for every environment. The .env file pattern loads variables from a file that is gitignored, and the ${VAR} syntax references them in the service definitions.
a. The three-tier stack
The canonical multi-service Compose application has three services: a web service, a database, and a cache. Each service has a different role and a different network requirement.
# compose.yaml
services:
web:
build: .
ports:
- "8080:3000"
environment:
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/appdb
REDIS_URL: redis://cache:6379
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
networks:
- frontend
- backend
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: appdb
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d appdb"]
interval: 5s
timeout: 5s
retries: 5
start_period: 10s
networks:
- backend
cache:
image: redis:7
command: redis-server --save "" --appendonly no
networks:
- backend
networks:
frontend:
backend:
internal: true
volumes:
db-data:
The web service is the only one with a published port. It is attached to both networks: frontend for external access and backend for database and cache access. The db and cache services are attached only to backend, which is marked internal: true. A container on an internal network cannot reach the internet, and the network itself is not reachable from outside the Docker host .
The service names are the hostnames. The web service reaches the database at db:5432 and the cache at cache:6379. This is the embedded DNS resolver that Compose creates on each user-defined network. The names are stable across container recreation, which is why the connection strings do not need to change when a container restarts.
b. Healthcheck-gated startup
The depends_on field with condition: service_healthy is the difference between a stack that starts reliably and one that crashes on the first request.
services:
web:
depends_on:
db:
condition: service_healthy
restart: true
db:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d appdb"]
interval: 10s
retries: 5
start_period: 30s
timeout: 10s
The healthcheck defines the command that determines whether the service is ready. pg_isready returns success when Postgres is accepting connections. The start_period gives the database time to initialize before the first check counts. The retries and interval determine how long Compose waits before giving up .
The restart: true field on the depends_on entry is a subtle but important detail. If the database is restarted due to an explicit Compose operation, the web service is also restarted automatically. This ensures that the web service re-establishes its connection pool rather than holding a stale connection to a database that has been replaced .
The documentation’s example log output shows the sequence: the database container starts, enters a Waiting state, passes the healthcheck, and only then does the web container move to Starting . Without the healthcheck condition, the Waiting โ Healthy step disappears, and the web container starts immediately after the database container’s process exists.
c. Environment variables and secrets
The .env file at the project root is loaded automatically by Compose. The ${VAR} syntax in the YAML file references the values.
# .env (gitignored)
DB_PASSWORD=changeme
services:
db:
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
The .env file should not be committed to version control. A .env.example file with placeholder values is committed instead, and the developer copies it to .env and fills in the real values. This pattern keeps the secrets out of the repository and makes the required variables visible to anyone reading the example file.
For production, the secrets top-level element provides a more structured mechanism. A secret is mounted as a file inside the container, typically under /run/secrets/, and the application reads the file instead of an environment variable. The environment variable approach is simpler for development; the secrets approach is more appropriate when the Compose file is deployed to a Swarm cluster or when the secret should not appear in the container’s environment .
Complete Example Session
# ============================================
# PART 1: THE WEB SERVICE
# ============================================
services:
web:
build: .
ports:
- "8080:3000"
environment:
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/appdb
REDIS_URL: redis://cache:6379
# ============================================
# PART 2: THE DEPENDENCY WITH HEALTHCHECK
# ============================================
depends_on:
db:
condition: service_healthy
restart: true
cache:
condition: service_started
# ============================================
# PART 3: THE DATABASE WITH HEALTHCHECK
# ============================================
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: appdb
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d appdb"]
interval: 5s
timeout: 5s
retries: 5
start_period: 10s
# ============================================
# PART 4: THE CACHE
# ============================================
cache:
image: redis:7
command: redis-server --save "" --appendonly no
# ============================================
# PART 5: NETWORK ISOLATION
# ============================================
networks:
- backend
networks:
frontend:
backend:
internal: true
# ============================================
# PART 6: THE WEB SERVICE NETWORKS
# ============================================
web:
networks:
- frontend
- backend
# web is on both networks.
# db and cache are on backend only.
# Only web can reach the internet.
# ============================================
# PART 7: THE VOLUME
# ============================================
volumes:
db-data:
# The database data survives container recreation.
# docker compose down removes the container but not the volume.
# ============================================
# PART 8: THE .ENV FILE
# ============================================
# .env (gitignored)
DB_PASSWORD=changeme
# .env.example (committed)
DB_PASSWORD=
# ============================================
# PART 9: STARTING THE STACK
# ============================================
docker compose up -d
# The startup order:
# 1. db container starts
# 2. cache container starts
# 3. db healthcheck runs
# 4. db becomes healthy
# 5. web container starts
# ============================================
# PART 10: VERIFYING THE STATE
# ============================================
docker compose ps
# db should show "(healthy)"
# web should be "Up"
docker compose logs web
# The web service logs should show a successful database connection.
The ten parts covered the web service, the dependency with healthcheck, the database healthcheck, the cache, network isolation, the web service networks, the volume, the .env file, starting the stack, and verifying the state.
Quick Reference
Multi-service structure
| Element | Purpose |
|---|---|
services | Defines each container |
networks | Defines frontend and backend networks |
volumes | Defines persistent data |
depends_on | Startup order |
healthcheck | Readiness gate |
depends_on conditions
| Condition | Meaning |
|---|---|
service_started | Container process exists (default) |
service_healthy | Healthcheck passes |
service_completed_successfully | Container exits 0 |
restart: true | Restart dependent on dependency restart |
Healthcheck fields
| Field | Purpose |
|---|---|
test | Command to run |
interval | Time between checks |
timeout | Max time per check |
retries | Failures before unhealthy |
start_period | Grace period before checks count |
Network isolation
| Network | Services | External access |
|---|---|---|
frontend | web | Yes |
backend (internal: true) | web, db, cache | No |
Environment variables
| Pattern | Use case |
|---|---|
${VAR} in YAML | Reference .env value |
.env | Local development, gitignored |
.env.example | Committed template |
secrets | Production, mounted as file |
Best Practices
โ Do This:
# Use healthcheck + condition for dependencies
depends_on:
db:
condition: service_healthy # โ
# Put the database and cache on an internal network
networks:
backend:
internal: true # โ
# Publish only the web service port
ports:
- "8080:3000" # โ
# Use .env for credentials
DB_PASSWORD=changeme # โ
โ Don’t Do This:
# Rely on depends_on alone for readiness
depends_on:
- db # โ
# Put the database on the frontend network
db:
networks:
- frontend # โ
# Publish the database port to the host
ports:
- "5432:5432" # โ
# Hardcode credentials in the compose file
environment:
POSTGRES_PASSWORD: secret # โ
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Web crashes on startup | depends_on without healthcheck | Add healthcheck and condition: service_healthy |
| Web cannot reach db | Different networks | Attach both to the same network |
| Web cannot reach internet | On internal: true network only | Attach web to a non-internal network |
| Database data lost | No named volume | Add top-level volumes and mount it |
| Healthcheck never passes | Wrong test command or start_period too short | Check the service’s actual readiness command |
| Web holds stale connection after db restart | No restart: true | Add restart: true to the depends_on entry |
Real-World Examples
1. Three-tier stack
services:
web: { ... }
db: { ... }
cache: { ... }
2. Healthcheck on database
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
3. Gated dependency
depends_on:
db:
condition: service_healthy
4. Internal backend network
networks:
backend:
internal: true
5. Web on both networks
networks:
- frontend
- backend
6. Named volume for database
volumes:
- db-data:/var/lib/postgresql/data
7. .env reference
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
8. Restart on dependency restart
depends_on:
db:
condition: service_healthy
restart: true
9. Cache with no persistence
command: redis-server --save "" --appendonly no
10. Verify stack state
docker compose ps
Visual
The three-tier stack
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ MULTI-SERVICE STACK โ
โ โ
โ Host port 8080 โ
โ โ โ
โ โผ โ
โ frontend network โ
โ โโโ web (build: .) โ
โ โ โ
โ โโโ backend network โโโบ db (postgres:16) โ
โ โ healthcheck: pg_isready โ
โ โ โ
โ โโโ backend network โโโบ cache (redis:7) โ
โ โ
โ backend is internal: true โ
โ db and cache have no external access. โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Startup order with healthcheck
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ STARTUP ORDER โ
โ โ
โ docker compose up โ
โ โ โ
โ โผ โ
โ db container starts โ
โ โ โ
โ โผ โ
โ cache container starts โ
โ โ โ
โ โผ โ
โ db healthcheck: pg_isready โ
โ โ โ
โ โโโ fails โโโบ retry (5s interval) โ
โ โ โ
โ โโโ passes โโโบ db healthy โ
โ โ โ
โ โผ โ
โ web container starts โ
โ โ โ
โ โผ โ
โ web connects to db:5432 and cache:6379 โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Network isolation
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ NETWORK ISOLATION โ
โ โ
โ Internet โ
โ โ โ
โ โผ โ
โ frontend network โ
โ โโโ web โโโบ backend network โ
โ โ โ
โ โโโ db โ
โ โโโ cache โ
โ โ
โ db and cache cannot reach the internet. โ
โ Only web can. โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The .env flow
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ ENVIRONMENT VARIABLES โ
โ โ
โ .env (gitignored) โ
โ DB_PASSWORD=changeme โ
โ โ โ
โ โผ โ
โ compose.yaml โ
โ environment: โ
โ POSTGRES_PASSWORD: ${DB_PASSWORD} โ
โ โ โ
โ โผ โ
โ db container โ
โ POSTGRES_PASSWORD=changeme โ
โ โ
โ .env.example (committed) has empty values. โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Summary
| Item | Value |
|---|---|
| Three-tier services | web, db, cache |
| Web network | frontend and backend |
| Database network | backend only |
| Cache network | backend only |
| Backend network | internal: true |
| Database healthcheck | pg_isready |
| Dependency condition | service_healthy |
| Restart on dependency restart | restart: true |
| Database volume | Named volume db-data |
| Credentials | .env file, ${VAR} reference |
Key takeaways:
- A multi-service Compose file is a description of relationships, not just containers. The
depends_onfield, thenetworksblocks, and thevolumesblocks are what make the stack work as a system rather than a collection of isolated containers. depends_onwithoutconditionis a race condition. Compose waits for the container to start, not for the application to be ready. A database container that is running may still be initializing. Thecondition: service_healthywith ahealthcheckon the dependency is the correct pattern .- Network isolation limits the blast radius. Only the web service should be on a network with external access. The database and cache belong on an internal network that cannot reach the internet and is not reachable from outside the Docker host .
- Service names are DNS names. The
webservice reaches the database atdb:5432because Compose runs an embedded DNS resolver on each user-defined network. The names are stable across container recreation, so the connection strings do not change. restart: trueon a dependency ensures reconnection. If the database is restarted, the web service is also restarted so it re-establishes its connection pool instead of holding a stale connection .- The
.envfile separates configuration from the Compose file. The YAML references${VAR}, and the actual values live in a gitignored file. The.env.examplefile documents the required variables without exposing the real values.
Remember: A multi-service Compose application is a system with dependencies. The web service depends on the database and the cache. The database depends on its volume and its healthcheck. The cache depends on nothing. The networks determine which services can talk to which, and the healthchecks determine when a dependency is actually ready. Get the healthcheck and the condition right first, because a stack that starts in the wrong order is a stack that crashes on the first request. Then isolate the database and cache on an internal network, publish only the web port, and keep the credentials in a .env file. The result is a stack that starts reliably, survives restarts, and does not expose the data layer to the network.
Stop using slow, ad-bloated tool sites! ๐คฎ
๐ Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
โ
Finance (Mortgage, Interest, Inflation)
โ
Tech (Base64, JSON, Dev Suite, IP)
โ
Health (BMI, BMR, TDEE)
โ
Productivity (Timer, Workspace, QR)
โก๏ธ Fast & Private
๐ No data leaves your device
๐ 100% Free
๐ Use it now: https://tools.kandz.me
๐ Bookmark itโyouโll need it later!