Prerequisites
- Docker Engine and Docker Compose installed (docker compose version)
- A basic single-container Dockerfile for at least one service
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
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.
Architecture
Three services on one private network — only the app's port is published to the host
Hands-On Lab
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 -vBest 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.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.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.
Cheat Sheet
Docker Compose
docker compose up -dStart all services in a compose.yaml, detacheddocker compose downStop and remove containers, networks created by updocker compose logs -f appFollow logs for a single Compose servicedocker compose exec app shOpen a shell inside a Compose servicedocker compose build --no-cacheRebuild images without using the layer cachedocker compose psList the status of all services in the stackFrequently Asked Questions
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.