Multi-container apps with Compose
One compose.yaml for an app, its database and their health checks.
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: deb157738d3dfc1093d5896a73a545923e538ed15d40b616b1b2235949928653Eight seconds after docker compose up -d, the database is up and the web app is already dead:
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:
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:
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);});
FROM node:24-alpineWORKDIR /appCOPY package.json package-lock.json ./RUN npm ci --omit=devCOPY server.js ./USER nodeEXPOSE 3000CMD ["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
name: lab-visitsservices:web:build: .ports:- "127.0.0.1:8080:3000"environment:DATABASE_URL: postgres://app:race-demo-only@db:5432/appdepends_on:- dbdb:image: postgres:18-alpineenvironment:POSTGRES_USER: appPOSTGRES_PASSWORD: race-demo-onlyPOSTGRES_DB: appvolumes:- pgdata:/var/lib/postgresqlvolumes: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:
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:
The fix: a health check and service_healthy
name: lab-visitsservices: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/appGREETING: ${GREETING:-hello}depends_on:db:condition: service_healthyrestart: unless-stoppeddb:image: postgres:18-alpineenvironment:POSTGRES_USER: appPOSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}POSTGRES_DB: appvolumes:- pgdata:/var/lib/postgresqlhealthcheck:test: ["CMD", "pg_isready", "-h", "127.0.0.1", "-U", "app", "-d", "app"]interval: 5stimeout: 3sretries: 5start_period: 30sstart_interval: 1srestart: unless-stoppedpsql:image: postgres:18-alpineprofiles: [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_healthyvolumes: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:
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:
# 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-passwordGREETING=helloWEB_PORT=8080
docker compose config prints the file after interpolation, which is the fastest way to see what Compose will actually do:
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:
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
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.
What docker compose up changes
Change the greeting in .env and run the same command again:
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:
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:
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:
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
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:
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.
depends_on: [db] and the web service exits at boot with "connection refused" about one time in three. Which change addresses the cause?GREETING in .env and edit server.js, then run docker compose up -d. What happens to the web service?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.