Running a web app, a DB, and a cache each with docker run means memorizing long commands per container and wiring up networks by hand. The more common problem is that the app comes up before the DB is ready and dies on a connection error. Docker Compose declares the whole stack in one YAML file and brings it up with a single command.
The thing that actually trips you up with multiple containers is service startup order. The stack below starts the app only after the DB is ready. Running docker compose up confirms the order holds in the log. The output comes from a run on Docker Compose v5.1.1.
One stack, one file
This is a stack of nginx (app), PostgreSQL (DB), and Redis (cache). The healthcheck on the db service and the api waiting for that health state via depends_on ... condition: service_healthy are the parts that matter.
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: secret
POSTGRES_DB: app
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d app"]
interval: 3s
timeout: 3s
retries: 10
cache:
image: redis:7-alpine
command: redis-server --save "" --appendonly no
api:
image: nginx:1.27-alpine
depends_on:
db:
condition: service_healthy # wait until db is "healthy"
cache:
condition: service_started # cache only needs to have started
ports:
- "8088:80"
volumes:
db-data:
The options in these service definitions do the following.
depends_on+condition: guarantees startup order.service_startedonly needs the container to come up;service_healthyrequires the health check to pass before moving on.healthcheck: a container being up does not mean the process inside is ready to accept requests.pg_isreadyperiodically checks whether Postgres is accepting connections.volumes:db-datastores the DB files so data survives even if the container is removed.ports: maps host 8088 to container 80.
db and cache expose no ports to the host. Inside the default network Compose creates, api reaches them by service name, so there is no need to open them externally.
Watching the startup order
docker compose up -d
After the images are pulled, the final container-creation log shows the order.
Container composedemo-cache-1 Started
Container composedemo-db-1 Started
Container composedemo-db-1 Waiting
Container composedemo-db-1 Healthy
Container composedemo-api-1 Starting
Container composedemo-api-1 Started
Right after db is Started, it goes into Waiting, passes its health check to become Healthy, and only then does api move to Starting. The DB comes up before the app, as declared. Drop the condition and keep only depends_on, and Compose waits only for the container to come up, not for health, so the Waiting → Healthy step disappears.
docker compose ps shows db marked (healthy).
NAME IMAGE SERVICE STATUS
composedemo-api-1 nginx:1.27-alpine api Up 11 seconds
composedemo-cache-1 redis:7-alpine cache Up 22 seconds
composedemo-db-1 postgres:16-alpine db Up 22 seconds (healthy)
A service name is a hostname
Services in the same Compose file are attached to an automatically created network and find each other by service name. Resolving the names from inside the api container shows Compose’s built-in DNS mapping each service to its container IP.
docker compose exec api sh -c "getent hosts db; getent hosts cache"
172.18.0.2 db db
172.18.0.3 cache cache
So application config uses service names like db:5432 and cache:6379, not IPs. Container IPs change on every restart, but service names do not.
The host-mapped port works too. With api’s port 80 mapped to 8088, it is reachable from the host.
curl -s -o /dev/null -w "HTTP %{http_code}\n" http://localhost:8088/
# HTTP 200
Tear the stack down with one command. -v removes the volumes as well.
docker compose down -v
Container composedemo-api-1 Removed
Container composedemo-db-1 Removed
Volume composedemo_db-data Removed
Network composedemo_default Removed
Splitting config by environment
One stack still has parts that differ between development and production. Compose can layer multiple files, placing environment-specific overrides on top of a common base. A file listed later overrides the one before it.
# docker-compose.override.yml — development (source mount, debug port)
services:
api:
volumes:
- ./src:/app
ports:
- "9229:9229"
environment:
- NODE_ENV=development
# dev: base + dev override
docker compose -f docker-compose.yml -f docker-compose.dev.yml up
# prod: base + prod override, in the background
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
If the file is named docker-compose.override.yml, plain docker compose up layers it on top of the base automatically.
Commands you reach for
| Command | Description |
|---|---|
docker compose up -d | Start the stack in the background |
docker compose up --build | Rebuild images, then start |
docker compose ps | Check service status (including health) |
docker compose logs -f api | Follow a specific service’s logs |
docker compose exec db psql -U postgres -d app | Run a command inside a container |
docker compose config | Validate and print the final merged config |
docker compose down -v | Stop the stack and remove volumes |
When a merge looks wrong, docker compose config prints the final result of all merged files. Startup failures are almost always a service coming up before its dependency is ready, which the healthcheck + condition: service_healthy combination above solves.