Orchestrating Multi-Service Apps with Docker Compose (Advanced)
One compose.yaml to run web, database, and cache together: custom networks, health checks, env vars, volumes, and restart policies.
Starting a single container is easy, but real apps are usually a bundle of "web + database + cache." Wiring them up one docker run at a time is tedious and it's easy to forget a network flag. Docker Compose lets you describe every service, network, and volume in one declarative compose.yaml, then bring the whole stack up with a single command. This guide assumes you're on your own server/VPS running Ubuntu or Debian, with Docker Engine and the Compose plugin already installed (a working docker compose version is all you need).
A web + db + redis example
Create compose.yaml in your project directory:
services:
web:
build: .
ports:
- "8080:8080"
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:
- backend
restart: unless-stopped
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: appdb
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
interval: 5s
timeout: 3s
retries: 5
networks:
- backend
restart: unless-stopped
cache:
image: redis:7
networks:
- backend
restart: unless-stopped
volumes:
db-data:
networks:
backend:
Custom networks let services talk
The networks block declares a custom bridge network, backend, that all three services join. Within a shared network, Compose provides service-name DNS: web reaches the others simply as the hostnames db and cache, with no need to publish ports to the host. Only web, which actually faces the outside world, maps 8080 via ports; keeping db and cache internal-only is safer.
dependson and healthcheck
dependson controls start order, but by default it only waits for a container to start, not to be ready. There's a gap between a database process launching and it accepting connections, so give db a healthcheck (probing with pgisready) and have web wait on condition: servicehealthy. That way web starts only once the database is genuinely up, avoiding a rejected first connection.
Environment variables and .env
Pull secrets out of the file. Create a .env in the same directory and Compose reads it automatically:
DB_PASSWORD=change-me-in-prod
Every ${DBPASSWORD} in compose.yaml gets substituted. Add .env to your .gitignore so it never lands in the repo.
Persisting data with volumes
Delete a container and its filesystem is gone, so a database needs a named volume: db-data:/var/lib/postgresql/data. Docker manages the volume, and even docker compose down leaves it intact, so recreating the container keeps your data (unless you explicitly run down -v).
Restart policy
restart: unless-stopped brings a container back after a crash or a server reboot, but respects the times you stopped it yourself. It's the common production choice; use on-failure if you only want retries on crashes.
Everyday commands
docker compose up -d # bring the whole stack up in the background
docker compose ps # check status
docker compose logs -f web # tail one service's logs
docker compose up -d --scale web=3 # scale web out to 3 replicas
docker compose down # stop and remove containers (named volumes kept)
Note that when using --scale you can't pin a single host ports mapping on that service (they'd collide) — typically you front it with a reverse proxy or use a port range.
Summary
The point of Compose is turning "how these containers cooperate" into one version-controlled file: custom networks let services find each other by name, healthcheck plus dependson enforce start order, .env centralizes config, named volumes protect your data, and restart adds self-healing. Get comfortable with up -d, logs -f, and scale, and you can run a solid multi-service app on any VPS.