Multi-container apps with Compose

One compose.yaml for an app, its database and their health checks.

Beginner20 min · lesson 13 of 14
Lesson files
The scripts, test data and local test servers this lesson uses, exactly as they ran on the lab machine (8 files, 3 KB): compose.tar.gz. The lab VM shares no folders with your computer, so fetch them inside the VM: cd ~/lab && curl -fsSLO https://secopslog.com/lab-files/docker-fund/compose.tar.gz && tar -xzf compose.tar.gz, which creates ~/lab/compose/. SHA-256: deb157738d3dfc1093d5896a73a545923e538ed15d40b616b1b2235949928653

Eight seconds after docker compose up -d, the database is up and the web app is already dead:

ubuntu@secopslog-docker:~/lab/compose · Docker 29.8.2
$ docker compose -f compose.race.yaml ps -a
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS lab-visits-db-1 postgres:18-alpine "docker-entrypoint.s…" db 9 seconds ago Up 8 seconds 5432/tcp lab-visits-web-1 lab-visits-web "docker-entrypoint.s…" web 9 seconds ago Exited (1) 8 seconds ago
$ docker compose -f compose.race.yaml logs web
web-1 | startup failed: connect ECONNREFUSED 172.18.0.2:5432

The web container started, tried to connect to PostgreSQL once, got ECONNREFUSED and exited with status 1, while Postgres was still initialising its data directory. The Compose file said depends_on: db, and Compose did exactly what that promises: it started db first, which says nothing about whether Postgres was ready to accept connections. This lesson builds a small two-service app with Docker Compose, reproduces that race on purpose, fixes it with a health check, and then covers the parts of Compose you use every day: .env interpolation, what up recreates, restart policies, profiles, and what down deletes. Run it on the main lab VM (secopslog-docker) in ~/lab/compose, where the lesson files unpack. They contain every file shown, including .env and .dockerignore, which a plain ls hides.

Compose in one paragraph

Docker Compose reads a YAML file (compose.yaml) that describes services (each service becomes one or more containers), plus the networks and volumes they use, and creates all of it with one command. It is a plugin of the Docker CLI, so the command is docker compose with a space:

ubuntu@secopslog-docker:~/lab/compose · Docker 29.8.2
$ docker compose version
Docker Compose version v5.6.0

v5.6.0 is current for this course. Compose jumped from v2 to v5 in December 2025, skipping 3 and 4 so the CLI version could not be confused with the old Compose file format versions. The Python tool docker-compose (with a hyphen) is the retired v1; old tutorials that use it, or start a file with a version: line, are out of date. Current Compose ignores a version: attribute and prints a warning telling you to remove it.

Compose works against one Docker engine. It creates and replaces containers on that host and nowhere else. It does not schedule containers across machines, move them off a failed node or roll out updates gradually. That makes it the right tool for development environments, CI test stacks and small single-server deployments, and not a replacement for Kubernetes. Running a stack across several machines is the subject of the Swarm lessons in Docker in depth.

The app

A Node web server counts visits in a PostgreSQL table. It connects to the database once at startup and exits if that fails, with no retry loop, so a database that is not ready is visible instead of hidden:

server.js
const http = require('node:http');
const { Client } = require('pg');
const greeting = process.env.GREETING || 'hello';
const db = new Client({ connectionString: process.env.DATABASE_URL });
async function main() {
// One connection attempt and no retry loop, so a database that is not ready yet is visible.
await db.connect();
await db.query('CREATE TABLE IF NOT EXISTS visits (at timestamptz NOT NULL DEFAULT now())');
http.createServer(async (req, res) => {
if (req.url === '/crash') {
console.log('crash requested, exiting with status 1');
process.exit(1);
}
await db.query('INSERT INTO visits DEFAULT VALUES');
const { rows } = await db.query('SELECT count(*) AS n FROM visits');
res.end(`${greeting}, visit ${rows[0].n}\n`);
}).listen(3000, () => console.log('listening on 3000'));
}
process.on('SIGTERM', () => process.exit(0));
main().catch((err) => {
console.error('startup failed:', err.message);
process.exit(1);
});
Dockerfile
FROM node:24-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY server.js ./
USER node
EXPOSE 3000
CMD ["node", "server.js"]

The Dockerfile follows "Writing a Dockerfile": npm ci installs exactly what package-lock.json pins (pg 8.23.1), and the process runs as the image's node user. The SIGTERM handler lets the app exit at once on docker compose down, since Node running as PID 1 would otherwise ignore the signal until Docker kills it after 10 seconds ("CMD, ENTRYPOINT and PID 1"). .dockerignore keeps .env and the Compose files out of the build context.

First draft: start order only

compose.race.yaml
name: lab-visits
services:
web:
build: .
ports:
- "127.0.0.1:8080:3000"
environment:
DATABASE_URL: postgres://app:race-demo-only@db:5432/app
depends_on:
- db
db:
image: postgres:18-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: race-demo-only
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql
volumes:
pgdata:

name: lab-visits sets the project name, which prefixes everything Compose creates. Without it, Compose uses the directory name. build: . builds the web image from the Dockerfile here; image: postgres:18-alpine pulls one. pgdata is a named volume mounted at /var/lib/postgresql, the path the PostgreSQL 18 images expect (the data itself goes in a versioned subdirectory, 18/docker; older guides mount /var/lib/postgresql/data, which is the PostgreSQL 17-and-earlier layout). In DATABASE_URL, db is the service name: Compose puts every service on a project network and registers each service name in the embedded DNS described in "Container networking basics".

Build the image, then start the stack. In a terminal Compose draws a live progress view; the lab captured the plain event list it prints when output is not a terminal:

ubuntu@secopslog-docker:~/lab/compose · Docker 29.8.2
$ docker compose -f compose.race.yaml build
Image lab-visits-web Building ... Image lab-visits-web Built
$ docker compose -f compose.race.yaml up -d
Network lab-visits_default Creating Volume lab-visits_pgdata Creating Volume lab-visits_pgdata Creating Network lab-visits_default Creating Volume lab-visits_pgdata Created Volume lab-visits_pgdata Created Network lab-visits_default Created Network lab-visits_default Created Container lab-visits-db-1 Creating Container lab-visits-db-1 Created Container lab-visits-web-1 Creating Container lab-visits-web-1 Created Container lab-visits-db-1 Starting Container lab-visits-db-1 Started Container lab-visits-web-1 Starting Container lab-visits-web-1 Started

Compose created the volume lab-visits_pgdata and the network lab-visits_default, then started db and, immediately after it, web. That is all depends_on: [db] means: the short form is condition: service_started. web then hit the database while it was still initialising. Remove the stack and its volume before the fixed version, so Postgres has to initialise from scratch again:

ubuntu@secopslog-docker:~/lab/compose · Docker 29.8.2
$ docker compose -f compose.race.yaml down -v
Container lab-visits-web-1 Stopping Container lab-visits-web-1 Stopped Container lab-visits-web-1 Removing Container lab-visits-web-1 Removed Container lab-visits-db-1 Stopping Container lab-visits-db-1 Stopped Container lab-visits-db-1 Removing Container lab-visits-db-1 Removed Network lab-visits_default Removing Volume lab-visits_pgdata Removing Volume lab-visits_pgdata Removed Network lab-visits_default Removed

The fix: a health check and service_healthy

compose.yaml
name: lab-visits
services:
web:
build: .
ports:
- "127.0.0.1:${WEB_PORT:-8080}:3000"
environment:
DATABASE_URL: postgres://app:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@db:5432/app
GREETING: ${GREETING:-hello}
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:18-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql
healthcheck:
test: ["CMD", "pg_isready", "-h", "127.0.0.1", "-U", "app", "-d", "app"]
interval: 5s
timeout: 3s
retries: 5
start_period: 30s
start_interval: 1s
restart: unless-stopped
psql:
image: postgres:18-alpine
profiles: [tools]
environment:
PGPASSWORD: ${POSTGRES_PASSWORD}
command: ["psql", "-h", "db", "-U", "app", "-d", "app", "-c", "SELECT count(*) AS visits FROM visits"]
depends_on:
db:
condition: service_healthy
volumes:
pgdata:

Three changes matter. db has a healthcheck: Docker runs pg_isready inside the container, every second during the 30-second start period (start_interval) and every 5 seconds after that, and marks the container healthy once it succeeds. web now waits for condition: service_healthy instead of a plain start. And both services have a restart policy, covered below.

-h 127.0.0.1 in the health check is deliberate. On first start the official Postgres image runs a temporary server to create the database and run init scripts, and that temporary server listens only on its Unix socket:

ubuntu@secopslog-docker:~/lab/compose · Docker 29.8.2
$ docker run --rm postgres:18-alpine grep -n "listen_addresses=''" /usr/local/bin/docker-entrypoint.sh
297: set -- "$@" -c listen_addresses='' -p "${PGPORT:-5432}"

listen_addresses='' turns TCP off for that phase. A plain pg_isready checks the Unix socket, so it can report ready during initialisation, before the real server accepts the TCP connections your app makes. Checking over TCP waits for the server your app will actually talk to. A useful health check tests what the client needs, which here is a TCP connection.

.env and interpolation

compose.yaml no longer contains the password. ${POSTGRES_PASSWORD:?...} is variable interpolation: Compose substitutes the value when it reads the file. It looks for variables in your shell environment first and then in a file named .env in the project directory:

.env
# Values for variable interpolation in compose.yaml. Local lab values only: keep real
# passwords out of Git (add .env to .gitignore).
POSTGRES_PASSWORD=lab-only-not-a-real-password
GREETING=hello
WEB_PORT=8080

docker compose config prints the file after interpolation, which is the fastest way to see what Compose will actually do:

ubuntu@secopslog-docker:~/lab/compose · Docker 29.8.2
$ docker compose config
name: lab-visits services: db: restart: unless-stopped environment: POSTGRES_DB: app POSTGRES_PASSWORD: lab-only-not-a-real-password POSTGRES_USER: app ... web: restart: unless-stopped environment: DATABASE_URL: postgres://app:lab-only-not-a-real-password@db:5432/app GREETING: hello ... ports: - mode: ingress host_ip: 127.0.0.1 target: 3000 published: "8080" protocol: tcp ...

The password and the greeting were filled in from .env, and ${WEB_PORT:-8080} became "8080". :- supplies a default when the variable is unset or empty. :? makes it required. Load an empty env file instead of .env and the required variable stops Compose before anything is created:

ubuntu@secopslog-docker:~/lab/compose · Docker 29.8.2
$ docker compose --env-file /dev/null config
time="2026-10-08T00:09:52+05:30" level=warning msg="The \"POSTGRES_PASSWORD\" variable is not set. Defaulting to a blank string." error while interpolating services.db.environment.POSTGRES_PASSWORD: required variable POSTGRES_PASSWORD is missing a value: set POSTGRES_PASSWORD in .env error while interpolating services.web.environment.DATABASE_URL: required variable POSTGRES_PASSWORD is missing a value: set POSTGRES_PASSWORD in .env

Exit status 1, with the message from the file. The warning line above it comes from the PGPASSWORD: ${POSTGRES_PASSWORD} entry in the psql service, which has no :?. Two consequences are easy to miss. .env feeds interpolation only: a variable reaches a container only if the file references it (as here) or you list it under environment: or env_file:. And docker compose config prints secrets in clear text, so do not paste its output into tickets. Keep .env out of Git when it holds real credentials. Compose also supports file-based secrets:, which "Runtime secrets, done right" in Advanced container security compares with environment variables.

Starting the fixed stack

ubuntu@secopslog-docker:~/lab/compose · Docker 29.8.2
$ docker compose up -d
Network lab-visits_default Creating Network lab-visits_default Creating Volume lab-visits_pgdata Creating Volume lab-visits_pgdata Creating Volume lab-visits_pgdata Created Volume lab-visits_pgdata Created Network lab-visits_default Created Network lab-visits_default Created Container lab-visits-db-1 Creating Container lab-visits-db-1 Created Container lab-visits-web-1 Creating Container lab-visits-web-1 Created Container lab-visits-db-1 Starting Container lab-visits-db-1 Started Container lab-visits-db-1 Waiting Container lab-visits-db-1 Healthy Container lab-visits-web-1 Starting Container lab-visits-web-1 Started
$ docker compose ps
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS lab-visits-db-1 postgres:18-alpine "docker-entrypoint.s…" db 6 seconds ago Up 6 seconds (healthy) 5432/tcp lab-visits-web-1 lab-visits-web "docker-entrypoint.s…" web 6 seconds ago Up 3 seconds 127.0.0.1:8080->3000/tcp
$ curl -s http://127.0.0.1:8080/; curl -s http://127.0.0.1:8080/
hello, visit 1 hello, visit 2
$ docker inspect -f '{{.State.Health.Status}} after {{len .State.Health.Log}} checks' lab-visits-db-1
healthy after 2 checks
$ docker network ls --filter name=lab-visits; docker volume ls --filter name=lab-visits
NETWORK ID NAME DRIVER SCOPE 9d9e614c4cd1 lab-visits_default bridge local DRIVER VOLUME NAME local lab-visits_pgdata

This time the event list contains Waiting and Healthy for db before web starts. docker compose ps shows (healthy) in the status column, 5432/tcp without a host address for db (reachable on the project network only, which is what a database should be), and 127.0.0.1:8080->3000/tcp for web. Two requests, two rows. The network and the volume carry the project prefix, so two projects on one host never collide.

The lab-visits project on one Docker host
Host
127.0.0.1:8080
published by web
lab-visits_default network
web (lab-visits-web-1)
built from ., waits for db healthy
db (lab-visits-db-1)
postgres:18-alpine, health check
psql (profile tools)
only with --profile tools
Volume
lab-visits_pgdata
mounted at /var/lib/postgresql
Service names (web, db, psql) resolve on the project network. Only web is published, and only on loopback.

What docker compose up changes

Change the greeting in .env and run the same command again:

ubuntu@secopslog-docker:~/lab/compose · Docker 29.8.2
$ sed -i 's/^GREETING=.*/GREETING=welcome back/' .env
$ docker compose up -d
Container lab-visits-db-1 Running Container lab-visits-web-1 Recreate Container lab-visits-web-1 Recreated Container lab-visits-db-1 Waiting Container lab-visits-db-1 Healthy Container lab-visits-web-1 Starting Container lab-visits-web-1 Started
$ curl -s http://127.0.0.1:8080/
welcome back, visit 3

Compose compared the configuration each existing container was created from with the file. db had not changed and kept Running; web had a different environment, so Compose replaced it (Recreate). The count continued at 3 because the data is in the volume, not in either container. That is the rule: plain docker compose up -d recreates exactly the services whose configuration changed. --force-recreate replaces every container even when nothing changed:

ubuntu@secopslog-docker:~/lab/compose · Docker 29.8.2
$ docker compose up -d --force-recreate
Container lab-visits-db-1 Recreate Container lab-visits-db-1 Recreated Container lab-visits-web-1 Recreate Container lab-visits-web-1 Recreated Container lab-visits-db-1 Starting Container lab-visits-db-1 Started Container lab-visits-db-1 Waiting Container lab-visits-db-1 Healthy Container lab-visits-web-1 Starting Container lab-visits-web-1 Started

Neither command rebuilds images. After editing server.js you need docker compose up -d --build (or docker compose build first); without it, Compose starts the old image. A container changed by hand with docker exec keeps that change only until the next recreate, which is why the file, not the container, is where configuration belongs.

Restart policies

restart: takes the same policies as docker run --restart, described in "docker run in depth": no (the default), on-failure[:N], always and unless-stopped. Both services here use unless-stopped. The /crash URL makes the app exit with status 1:

ubuntu@secopslog-docker:~/lab/compose · Docker 29.8.2
$ curl -s http://127.0.0.1:8080/crash
$ docker inspect -f '{{.State.Status}}, restart count {{.RestartCount}}, policy {{.HostConfig.RestartPolicy.Name}}' lab-visits-web-1
running, restart count 1, policy unless-stopped
$ docker compose logs --no-log-prefix web
listening on 3000 crash requested, exiting with status 1 listening on 3000

curl exited 52 (empty reply) because the process died mid-request. Docker started it again, the restart count went to 1, and the log shows the second listening on 3000. A restart policy also would have hidden the race from the first draft: web would have crashed and restarted until Postgres was ready. Retries are useful as a safety net, but they leave a crash in the logs on every start, and a real failure looks the same as the race. The health check removes the race itself.

The health gate only covers start-up. If db is later recreated (a new image, a changed setting) or restarts, an app that connected once, like this one, loses its connection, and nothing restarts web except its own crash and restart policy. Applications should reconnect by themselves. Compose can also help: restart: true under depends_on.db, next to the condition, makes Compose restart web whenever it recreates or restarts db through a Compose command.

Profiles for optional services

The psql service carries profiles: [tools], so a normal up ignores it. It exists only when you name its profile:

ubuntu@secopslog-docker:~/lab/compose · Docker 29.8.2
$ docker compose config --services; docker compose --profile tools config --services
db web db psql web
$ docker compose --profile tools run --rm psql
Container lab-visits-db-1 Running Container lab-visits-db-1 Waiting Container lab-visits-db-1 Healthy Container lab-visits-psql-run-1a07209adcc1 Creating Container lab-visits-psql-run-1a07209adcc1 Created visits -------- 3 (1 row)

docker compose run --rm starts a one-off container for that service on the project network, waits for db to be healthy because of its own depends_on, runs the query (3 visits so far) and removes the container. Profiles suit debugging tools, admin UIs and seed jobs you want versioned with the stack but not running all the time.

down and down -v

ubuntu@secopslog-docker:~/lab/compose · Docker 29.8.2
$ docker compose down
Container lab-visits-web-1 Stopping Container lab-visits-web-1 Stopped Container lab-visits-web-1 Removing Container lab-visits-web-1 Removed Container lab-visits-db-1 Stopping Container lab-visits-db-1 Stopped Container lab-visits-db-1 Removing Container lab-visits-db-1 Removed Network lab-visits_default Removing Network lab-visits_default Removed
$ docker volume ls --filter name=lab-visits
DRIVER VOLUME NAME local lab-visits_pgdata
$ docker compose up -d
Network lab-visits_default Creating Network lab-visits_default Creating Network lab-visits_default Created Network lab-visits_default Created Container lab-visits-db-1 Creating Container lab-visits-db-1 Created Container lab-visits-web-1 Creating Container lab-visits-web-1 Created Container lab-visits-db-1 Starting Container lab-visits-db-1 Started Container lab-visits-db-1 Waiting Container lab-visits-db-1 Healthy Container lab-visits-web-1 Starting Container lab-visits-web-1 Started
$ curl -s http://127.0.0.1:8080/
welcome back, visit 4

docker compose down removed the containers and the network and kept the named volume, so the next up continued at visit 4. Deleting data takes an explicit -v. --rmi local also removes images Compose built without a custom image: name, which is how the lab leaves nothing behind:

ubuntu@secopslog-docker:~/lab/compose · Docker 29.8.2
$ docker compose down -v --rmi local
Container lab-visits-web-1 Stopping Container lab-visits-web-1 Stopped Container lab-visits-web-1 Removing Container lab-visits-web-1 Removed Container lab-visits-db-1 Stopping Container lab-visits-db-1 Stopped Container lab-visits-db-1 Removing Container lab-visits-db-1 Removed Volume lab-visits_pgdata Removing Network lab-visits_default Removing Network lab-visits_default Removed Volume lab-visits_pgdata Removed Image lab-visits-web:latest Removing Image lab-visits-web:latest Removed
$ docker volume ls --filter name=lab-visits; docker image ls lab-visits-web
DRIVER VOLUME NAME IMAGE ID DISK USAGE CONTENT SIZE EXTRA

docker compose down -v on a stack with a database deletes the database. On a shared or production host, read the command twice. If you add --scale web=2 to this project, the second replica fails because both containers want host port 8080: one host, one listener per address and port, which is another reminder of where Compose stops.

Quick check
01A stack uses depends_on: [db] and the web service exits at boot with "connection refused" about one time in three. Which change addresses the cause?
Incorrect — It retries until Postgres happens to be ready. The crash still happens, and a real failure looks the same as the race.
Incorrect — That replaces containers; start order and readiness stay exactly the same.
Incorrect — Services on the project network reach each other without publishing. The port was never the problem.
Correct — Compose then starts web only after the database passes its check, instead of right after its container starts.
02You change GREETING in .env and edit server.js, then run docker compose up -d. What happens to the web service?
Incorrect — Compose compares each container's configuration with the file and recreates changed services.
Incorrect — up does not rebuild an image that already exists. That needs --build.
Correct — The environment changed, so the container is replaced, but the image (and server.js inside it) is the old one until you add --build.
Incorrect — Only services whose resulting configuration changed are recreated. db does not use GREETING.
03Which statement about this project's named volume is true?
Incorrect — There is no --keep. down keeps named volumes by default.
Correct — -v removes the named volumes declared in the file, so the visit count starts over.
Incorrect — Recreate replaces the container; the volume is reattached with its data, as visit 3 showed.
Incorrect — A named volume lives outside the container, which is why it survives recreate and down.

Try this

Work through “down and down -v” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

If you keep one thing from multi-container apps with compose, keep “down and down -v”. Decide now which check you will run when this shows up on a live system, and write it somewhere your team will find it.

Related