| |

LFCE 135 ๐Ÿง Docker Compose โ€” Multi-Service Apps

โฑ๏ธ Estimated read: 11 min | |

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

ElementPurpose
servicesDefines each container
networksDefines frontend and backend networks
volumesDefines persistent data
depends_onStartup order
healthcheckReadiness gate

depends_on conditions

ConditionMeaning
service_startedContainer process exists (default)
service_healthyHealthcheck passes
service_completed_successfullyContainer exits 0
restart: trueRestart dependent on dependency restart

Healthcheck fields

FieldPurpose
testCommand to run
intervalTime between checks
timeoutMax time per check
retriesFailures before unhealthy
start_periodGrace period before checks count

Network isolation

NetworkServicesExternal access
frontendwebYes
backend (internal: true)web, db, cacheNo

Environment variables

PatternUse case
${VAR} in YAMLReference .env value
.envLocal development, gitignored
.env.exampleCommitted template
secretsProduction, 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

PitfallWhy It HappensFix
Web crashes on startupdepends_on without healthcheckAdd healthcheck and condition: service_healthy
Web cannot reach dbDifferent networksAttach both to the same network
Web cannot reach internetOn internal: true network onlyAttach web to a non-internal network
Database data lostNo named volumeAdd top-level volumes and mount it
Healthcheck never passesWrong test command or start_period too shortCheck the service’s actual readiness command
Web holds stale connection after db restartNo restart: trueAdd 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

ItemValue
Three-tier servicesweb, db, cache
Web networkfrontend and backend
Database networkbackend only
Cache networkbackend only
Backend networkinternal: true
Database healthcheckpg_isready
Dependency conditionservice_healthy
Restart on dependency restartrestart: true
Database volumeNamed 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_on field, the networks blocks, and the volumes blocks are what make the stack work as a system rather than a collection of isolated containers.
  • depends_on without condition is 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. The condition: service_healthy with a healthcheck on 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 web service reaches the database at db:5432 because 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: true on 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 .env file separates configuration from the Compose file. The YAML references ${VAR}, and the actual values live in a gitignored file. The .env.example file 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.

Was this helpful?


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!