Skip to content

Docker Compose for Multi-Container Applications

One command to bring up an app, its database, and its cache — correctly networked, correctly ordered, and correctly persisted.

Docker EngineDocker Compose
2 min readIntermediate
Lab time: 25-35 min
RR

Ranjith R

Linux SysAdmin & Cloud Engineer

Published July 1, 2026Updated July 6, 2026
Share
Section 01

Prerequisites

  • Docker Engine and Docker Compose installed (docker compose version)
  • A basic single-container Dockerfile for at least one service
Section 02

What You'll Learn

  • Define multiple services in a compose.yaml with a private network between them
  • Use named volumes to persist database data across container restarts
  • Use depends_on with healthchecks so an app waits for its database to be ready
  • Manage environment-specific config with .env files and override files
Section 03

Theory

Compose reads a compose.yaml describing one or more services, and by default creates a single user-defined bridge network shared by all of them — every service can reach every other by its service name as a hostname (Docker's embedded DNS resolves it), with no manual linking or IP management required.

Why depends_on alone isn't enough

depends_onwithout conditions only waits for the dependency's container to start, not for the process inside it to actually be ready to accept connections — a database container can report "started" seconds before Postgres has finished initializing. A healthcheck combined with depends_on: condition: service_healthy closes that gap by waiting for the dependency to report itself as actually ready.

Named volumes vs. bind mounts

A named volume (db-data:/var/lib/postgresql/data) is managed entirely by Docker and persists independently of the container's lifecycle — recreating the container leaves the volume untouched. A bind mount maps a specific host path into the container, useful for live-reloading source code during development, but ties the setup to the host's filesystem layout.

Section 04

Architecture

Three services on one private network — only the app's port is published to the host

Section 05

Hands-On Lab

compose.yaml
services:
  app:
    build: .
    ports:
      - "8080:3000"
    environment:
      DATABASE_URL: postgres://app:app@db:5432/appdb
      REDIS_URL: redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started

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

  cache:
    image: redis:7-alpine

volumes:
  db-data:
docker compose up -d
docker compose ps                    # confirm all three services are healthy/running
docker compose logs -f app           # follow the app's logs
docker compose exec db psql -U app -d appdb -c '\dt'   # confirm the app can reach the DB

# Bring it all down, including the named volume, for a clean slate:
docker compose down -v
Section 07

Best Practices

Only publish the ports that need host access

The database and cache services in the lab have no `ports:` entry — they're reachable by other services on the network by name, but not from the host or the internet. Publish only what genuinely needs external access.

Split secrets into a .env file, never commit it

Reference variables as ${DB_PASSWORD} in compose.yaml and keep the actual values in a .env file listed in .gitignore — commit a .env.example with placeholder values instead.
Section 08

Common Mistakes

Using depends_on without a healthcheck for a database

The app container starts immediately after the DB container starts — not after Postgres is actually accepting connections — causing intermittent "connection refused" errors on a fresh `compose up` that don't reproduce once everything happens to already be warm.

Forgetting -v on compose down leaves stale data

`docker compose down` alone removes containers and networks but keeps named volumes — the next `up` reuses old database data, which is usually desired, but surprises anyone expecting a truly clean slate. Add -v when you actually want to reset state.
Section 09

Troubleshooting

App can't resolve the "db" hostname:confirm both services are actually on the same Compose network — services only share Compose's default network if they're defined in the same compose.yaml (or explicitly joined via a shared networks: key across files); a service started with docker runoutside Compose won't be on that network at all.

Healthcheck never turns healthy: run the exact healthcheck command manually inside the container (docker compose exec db pg_isready -U app) to see the real error, rather than guessing from the healthy/unhealthy status alone.

Section 11

Cheat Sheet

Docker Compose

docker compose up -dStart all services in a compose.yaml, detached
docker compose downStop and remove containers, networks created by up
docker compose logs -f appFollow logs for a single Compose service
docker compose exec app shOpen a shell inside a Compose service
docker compose build --no-cacheRebuild images without using the layer cache
docker compose psList the status of all services in the stack
Get the full cheat sheet (with PDF download)
FAQ

Frequently Asked Questions

Yes, via override files — keep a base compose.yaml with shared config, then a compose.override.yaml (applied automatically) for local dev extras like bind-mounted source code, and a separate compose.prod.yaml applied explicitly (docker compose -f compose.yaml -f compose.prod.yaml up) for production-specific settings.
Section 12

Summary

Compose networks every service together by name on a private bridge network, named volumes persist data independent of container lifecycle, and healthcheck-gated depends_on closes the gap between "container started" and "service actually ready" — the difference between a demo that mostly works and a stack that starts reliably every time.