Docker Compose Single Service
A docker-compose.yml for local development of a Node.js web app with a PostgreSQL database — featuring health-check-gated startup ordering, named volume persistence, and a bind mount for live code reloading.
Overview
This Compose file defines two services: web (your Node.js application, built from the local Dockerfile) and db (a PostgreSQL 16 instance using the official Alpine image). Together they form the minimal viable local development environment for a database-backed web app — everything a new team member needs to get running is expressed in this single file.
The file is deliberately kept simple enough to understand at a glance, yet includes the patterns that prevent the most common pitfalls: the web service waits for Postgres to actually be ready (not just started), data is persisted across container restarts via a named volume, and the local source code is mounted into the container so that changes are reflected without rebuilding the image. These three features together make the environment feel like a native dev setup while keeping it fully reproducible and isolated.
Complete YAML
services:
web:
build: .
ports:
- "3000:3000"
environment:
NODE_ENV: development
DATABASE_URL: postgresql://postgres:password@db:5432/myapp
depends_on:
db:
condition: service_healthy
volumes:
- .:/app
- /app/node_modules
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: myapp
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:
depends_on with health checks
The depends_on key controls the startup order of services. In its simplest form — depends_on: [db] — Compose will start the db container before starting web, but it only waits for the container to start, not for the database inside it to be ready. PostgreSQL can take several seconds to initialise its data directory on first run, during which your application's connection attempts will fail with "connection refused" errors.
The solution is condition: service_healthy. This tells Compose to wait until the db service's healthcheck reports a healthy status before starting web. The healthcheck on the db service runs pg_isready -U postgres — a lightweight PostgreSQL utility that exits 0 when the server is accepting connections. The check runs every 5 seconds, times out after 5 seconds, and must succeed 1 time (the default) after up to 5 retries before the service is considered healthy.
The test field accepts either a string (run as a shell command) or a list. Using the list form ["CMD-SHELL", "..."] runs the command inside a shell, which is necessary for commands that use shell features like pipes or variable expansion. The alternative ["CMD", "pg_isready", "-U", "postgres"] would exec the binary directly without a shell — both work for this simple case, but the shell form is more flexible.
Volumes
Docker Compose supports two types of volume mounts. A bind mount maps a path on the host machine directly into the container — the - .:/app entry maps your project root (.) to /app inside the web container. Any file change you make on your host is immediately visible inside the container, enabling live-reload workflows with tools like nodemon or Vite's dev server.
A named volume is managed entirely by Docker and lives in Docker's own storage area on the host. The postgres_data volume stores PostgreSQL's data directory at /var/lib/postgresql/data. When you run docker compose down, Compose stops and removes the containers, but the volume persists. Your database data survives container restarts and rebuilds. To reset the database completely, use docker compose down -v, which removes volumes as well.
Named volumes must be declared in the top-level volumes block (the final four lines of this file). Without this declaration, Compose will produce an error. You can add configuration to named volumes — for example, to use a specific volume driver or to set driver options for network-attached storage — but an empty declaration like postgres_data: is perfectly valid and tells Compose to use the default local driver.
The node_modules volume trick
The - /app/node_modules entry in the web service's volumes list is an anonymous volume mount with no host path — just a container path. This exploits Docker's volume precedence rules: when multiple volumes are mounted, and one is a bind mount covering a directory while another is an anonymous volume covering a subdirectory of that same path, the anonymous volume "wins" for that subdirectory.
Without this trick, the bind mount - .:/app would map your entire project root — including your host's node_modules — into the container. On macOS or Windows this causes two serious problems: host node_modules may contain platform-specific binary builds incompatible with the Linux container, and the macOS/Windows filesystem binding has significantly slower I/O than Linux native, making npm operations inside the container painfully slow.
The anonymous volume on /app/node_modules shields that directory from the bind mount. When npm ci runs during the Docker image build (in your Dockerfile), it installs dependencies into the image layer. When Compose mounts the bind mount at runtime, the anonymous volume intercepts access to /app/node_modules and serves the in-image node_modules instead. This gives you live host code reloading for your source files while keeping a clean, Linux-native node_modules directory.
package.json or package-lock.json, you need to rebuild the image (docker compose build) and then restart the stack to get the updated node_modules. The anonymous volume will be replaced with a fresh one from the rebuilt image.