Docker & Containers · Apr 2026 · 13 min read
Docker Compose for Real Local Dev Parity
An app and a Postgres database, wired together with health-gated startup ordering and a named volume — torn all the way down and brought back up to prove the data actually survives, not just assumed to.
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