The Compose File That Looks Right on Paper
Most Docker Compose tutorials show an app service and a database service, wire them together with depends_on, and call it done. The gap between that and a Compose setup that actually mirrors production is usually invisible until the app starts before the database is ready to accept connections, or a "restart" quietly wipes data nobody meant to lose.
This article builds a real two-service app — Express talking to Postgres, with an endpoint that actually writes and reads rows — and proves two specific things that a Compose file looks like it does but doesn't automatically do: that the app genuinely waits for the database to be ready, and that data survives a full teardown, not just a container restart.
Before Step 1, one term this walkthrough leans on:
-
depends_onwithcondition: service_healthy— plaindepends_on: [db]only waits for the database container to start, not for Postgres inside it to actually be accepting connections — those are different moments, often seconds apart.condition: service_healthymakes Compose wait for the dependency's ownhealthcheckto pass first, which is the actual guarantee an app needs before its first query.
Step 1 — An app with a real reason to need a database
app.get("/visits", async (req, res) => {
try {
await pool.query(
"CREATE TABLE IF NOT EXISTS visits (id SERIAL PRIMARY KEY, seen_at TIMESTAMPTZ DEFAULT now())"
);
await pool.query("INSERT INTO visits DEFAULT VALUES");
const result = await pool.query("SELECT COUNT(*) FROM visits");
res.json({ visits: Number(result.rows[0].count) });
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
Why a visit counter and not a health check stub: the whole point of this article is proving data persistence, which requires a route that actually writes something meaningful to Postgres and a way to observe that value changing and then surviving. A counter that increments on every request is the smallest thing that does both.
Step 2 — The Compose file, with the two guarantees made explicit
services:
app:
build: .
ports:
- "3000:3000"
environment:
DB_HOST: db
DB_PORT: 5432
DB_USER: appuser
DB_PASSWORD: ${DB_PASSWORD:-devpassword}
DB_NAME: compose_lab
depends_on:
db:
condition: service_healthy
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: ${DB_PASSWORD:-devpassword}
POSTGRES_DB: compose_lab
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser -d compose_lab"]
interval: 5s
timeout: 5s
retries: 5
volumes:
db-data:
Why DB_HOST: db works at all: Compose creates a private network for the whole file automatically, and every service can reach every other service by its service name as a hostname — app connects to db, not to localhost or a hardcoded IP, because Compose's built-in DNS resolves db to whichever container is currently running that service.
Why the healthcheck runs pg_isready, not just a TCP check: Postgres's own port can accept a TCP connection slightly before the database has finished its startup sequence and is ready to run real queries. pg_isready is Postgres's own readiness probe — the same signal a production orchestrator would use — so condition: service_healthy is waiting on the same guarantee a real deployment would.
Why the volume is named, not a host bind mount: db-data:/var/lib/postgresql/data creates a Docker-managed named volume, independent of any specific container. When the db container is removed and recreated, Compose reattaches the same volume — the data was never inside the container's own writable layer to begin with.
Step 3 — Bringing it up for real (no local Docker, so this ran on a real Azure VM)
sudo docker compose up -d --build
Container compose-app-db-1 Starting
Container compose-app-db-1 Started
Container compose-app-db-1 Waiting
Container compose-app-db-1 Healthy
Container compose-app-app-1 Starting
Container compose-app-app-1 Started
That Waiting → Healthy step, appearing between the database starting and the app starting, is condition: service_healthy doing its actual job — visible in the log, not just configured and hoped for.
curl -s localhost:3000/visits
curl -s localhost:3000/visits
curl -s localhost:3000/visits
{"visits":1}
{"visits":2}
{"visits":3}
Step 4 — Proving persistence means tearing everything down, not restarting
A container restart is a weak test — the same container, same writable layer, same everything, just re-executed. Real proof means removing the containers entirely and recreating them from the Compose file, which is what docker compose down followed by docker compose up actually does:
sudo docker compose down
sudo docker compose up -d
Container compose-app-app-1 Removed
Container compose-app-db-1 Removed
Network compose-app_default Removed
Network compose-app_default Created
Container compose-app-db-1 Created
Container compose-app-app-1 Created
Container compose-app-db-1 Healthy
Container compose-app-app-1 Started
curl -s localhost:3000/visits
{"visits":5}
Both containers were fully removed and rebuilt from scratch — new container IDs, new network — and the counter picked up at 5, exactly where it left off, instead of resetting to 1. That's the named volume doing its job: the containers are disposable, the data isn't, and this is the one moment this article actually proves that distinction rather than describing it.
Closing Thoughts
A Compose file with depends_on and a volumes: block looks like it guarantees startup ordering and data persistence — most of the syntax exists specifically for that purpose. Whether it actually delivers on either guarantee is a different question, answered by watching the Waiting → Healthy transition happen in real output, and by tearing the whole stack down and confirming the data comes back rather than assuming a restart proved anything. Local dev parity with production isn't the YAML syntax — it's whether the failure modes production cares about (a service starting before its dependency is ready, a container recreation losing state) are things you've actually watched not happen.
GitHub Repository: docker-multistage-lab — the compose-app/ directory has the full Express + Postgres app, the Compose file, and a .env.example for the database password.
Reviewed against Docker Compose v5.5.1 as of September 2026.
Docker · Docker Compose · PostgreSQL · Local Development · Health Checks
Originally published on my portfolio.
Top comments (0)