By Ryan Calloway. Updated May 2026.
The minimum useful compose.yaml for a real web project is under 80 lines: one Node service, Postgres for persistent state, Redis for cache and queues, named volumes for data, healthchecks on the dependencies so the app waits for them, and the default network. That single shape repeats across most public starter templates on GitHub and is the answer that gets upvoted in every recurring r/docker “what’s the minimum compose file for a real project” thread (e.g. this one). Docker still sits at the top of the Stack Overflow 2025 “most-used tools” list for professional developers, and Compose is how the daily workflow looks for almost everyone using it. This tutorial is the exact starter file for Node.js + Postgres + Redis, the four commands that cover 95% of daily use, and the six mistakes that show up in week one.
What you’ll build
A working local stack with three services in one compose.yaml:
- api — Node.js 22 LTS service built from a local
Dockerfile. - db — Postgres 17 with a healthcheck and a named volume for data.
- cache — Redis 7.4 with persistence enabled and a healthcheck.
You will run docker compose up -d, watch logs, exec into a running container, persist data across restarts, and tear everything down cleanly. The api connects to db:5432 and cache:6379 by service name; nothing leaks to the host except the API port.
Prerequisites
- Docker Engine 27+ and Docker Compose v2.30+ (run
docker compose version; Compose v2 is bundled in Docker Desktop and moderndockerCLI installs). - About 4 GB free disk for images and volumes.
- A text editor.
- Familiarity with the command line. No Docker experience required — this is the entry point.
Compose Spec is the language; the canonical reference is the Compose file reference. The legacy version: "3.8" top-level key is ignored by Compose v2 and was dropped from the docs in 2023; do not add it to new files.
Step 1: project layout and a tiny Node app
Lay out the project root:
my-app/
Dockerfile
compose.yaml
package.json
server.js
.env.example
.dockerignore
.gitignore
A minimal server.js that talks to both Postgres and Redis on startup:
import express from "express";
import pg from "pg";
import { createClient } from "redis";
const app = express();
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
app.get("/health", async (_, res) => {
const { rows } = await pool.query("SELECT 1 AS ok");
const pong = await redis.ping();
res.json({ db: rows[0].ok === 1, redis: pong === "PONG" });
});
app.listen(3000, () => console.log("API on :3000"));
And a matching package.json using "type": "module" with express, pg, and redis as dependencies. The application is intentionally tiny — the goal is to verify Compose plumbing, not to build a CRUD app.
Step 2: write the Dockerfile
# Dockerfile
FROM node:22-bookworm-slim AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
FROM node:22-bookworm-slim
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NODE_ENV=production
USER node
EXPOSE 3000
CMD ["node", "server.js"]
Two-stage build keeps the final image small and avoids shipping dev dependencies. The USER node line drops root inside the container; running as root is one of the most common Docker security mistakes. Add a .dockerignore with at minimum node_modules, .env, .git, and compose.yaml — otherwise the build context bloats and you ship secrets into the image.
Step 3: write compose.yaml with healthchecks
services:
api:
build: .
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://app:${POSTGRES_PASSWORD}@db:5432/app
REDIS_URL: redis://cache:6379
NODE_ENV: development
depends_on:
db:
condition: service_healthy
cache:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:17
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: app
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
cache:
image: redis:7.4-alpine
command: ["redis-server", "--appendonly", "yes"]
volumes:
- cache-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
restart: unless-stopped
volumes:
db-data:
cache-data:
That is the entire stack. Three services, two named volumes, one default network that Compose creates implicitly. The api reaches the database at db:5432 and Redis at cache:6379 via Compose’s internal DNS — the service name is the hostname.
Step 4: configure secrets via .env
Create .env at the project root (and add it to .gitignore):
# .env (gitignored)
POSTGRES_PASSWORD=local-only-not-for-prod
Ship a committed .env.example with the same keys and placeholder values. Compose loads .env automatically and substitutes ${POSTGRES_PASSWORD} in the YAML.
Three layers of environment variable precedence, lowest to highest:
.envfile at the project root. Loaded automatically.- Inline in
compose.yaml. Overrides the.env. - Shell environment. If a variable is set in the shell when you run
docker compose up, it overrides everything else.
Use ${VAR:-default} for “use this value if not set”; the full rules are in the Compose environment-variables docs. For real secrets in production, switch to Compose secrets or a vault — .env is fine for local-dev passwords, not for production credentials.
Step 5: bring it up and verify
docker compose up -d --build
docker compose ps
docker compose logs -f api
curl http://localhost:3000/health
# {"db":true,"redis":true}
If the API logs an ECONNREFUSED on startup, the healthchecks are doing their job — the api waited too long and gave up, or the database init password did not match. Inspect the dependency:
docker compose exec db psql -U app -d app -c "select version();"
docker compose exec cache redis-cli info server | head
docker compose exec drops you into the running container without a separate docker ps + docker exec dance. This is the command you use 20 times a day.
Step 6: hot reload for development
For active development, you want code changes to reflect in the container without a rebuild. Add an override file compose.dev.yaml:
services:
api:
build:
target: deps
command: ["node", "--watch", "server.js"]
environment:
NODE_ENV: development
volumes:
- ./:/app
- /app/node_modules # anonymous volume to shield host's empty dir
ports:
- "9229:9229" # Node inspector port
Run dev with both files merged:
docker compose -f compose.yaml -f compose.dev.yaml up -d
Compose merges arrays by appending and overrides scalars from the latter file. The bind mount ./:/app shows host changes inside the container instantly; the named anonymous volume on /app/node_modules stops the host’s (empty) node_modules from clobbering the image’s installed dependencies. Most teams alias these as make dev and make prod in a Makefile.
Step 7: the four commands you need every day
| Command | What it does |
|---|---|
docker compose up -d |
Build (if needed) and start all services in the background. |
docker compose logs -f <service> |
Stream logs from one or all services. |
docker compose exec <service> sh |
Open a shell inside a running container. |
docker compose down -v |
Stop and remove containers, networks, and named volumes (the -v wipes data — do not do this in muscle memory). |
Memorise these four. Everything else (build, pull, restart, config, cp) is occasional. The Compose CLI reference has the full list.
Common pitfalls
- Plain
depends_on: [db]without a healthcheck condition. Compose waits for the container to start, not for Postgres to accept connections. The first three connections fail and you blame Compose. Use the long form withcondition: service_healthyas in Step 3. - Exposing every service’s port to the host. Only the entry-point service (
api) needsports. Inter-service communication goes via the internal network. Exposing Postgres on 5432 to the host is how local credentials end up reused in production by mistake. - Anonymous volumes for data.
volumes: ["/var/lib/postgresql/data"]works but you cannot tell which volume holds what; a futuredocker volume prunewipes it. Always name volumes (as indb-dataabove). - Hardcoded passwords in committed files. Use
.envfor dev and Compose secrets or a vault for production. Audit your git history withgit log -p compose.yamlon day one. - Bind mount over the entire project without shielding
node_modules. Mounting./:/appoverrides the image’snode_moduleswith the host’s empty one. Either bind-mount specific subdirs or use the anonymous-volume trick from Step 6. - No
restartpolicy on long-running services. Addrestart: unless-stoppedfor services that should come back after a host reboot or a container crash. Default isno, which means a single OOM-kill takes the service offline until you notice.
Each one is the same shape: a default that works for the demo and breaks under one specific real-world wrinkle. The healthcheck reference covers the full syntax for tuning.
Production: you can use Compose, but you usually shouldn’t
Compose runs production fine for solo projects and small services on a single VM. Past two hosts or five services it stops being the right tool; you want Docker Swarm (rare) or Kubernetes (common). The signal that you have outgrown Compose: any of “I need rolling updates”, “I need autoscaling”, “I need multi-host networking”, or “I need real secret management”. The recurring r/devops thread “Docker Compose in production” walks through the trade-offs honestly. For staging and CI, Compose is excellent. For wiring this into CI, see the GitHub Actions tutorial.
FAQ
What is the difference between Docker and Docker Compose?
Docker runs one container at a time via docker run. Compose orchestrates multiple containers from a YAML file: builds, starts, networks them, manages volumes, and tears them down together. You always have Docker; Compose is a tool on top, bundled with Docker Desktop and modern Docker Engine installs.
How do I keep data between docker compose down and up?
Use a named volume for the data path. docker compose down alone removes containers but keeps named volumes. docker compose down -v also removes the volumes — do not run that in muscle memory.
Can I run different versions of the same stack in parallel?
Yes, by giving each one a project name. docker compose -p project-a up and docker compose -p project-b up run two isolated stacks, with their own networks and volumes.
How do I rebuild only one service?
docker compose up -d --build <service> rebuilds and replaces the running container in one step. docker compose build <service> rebuilds the image without restarting. The first form is what you want most days.
Why is my container exiting immediately?
The container’s main process exited non-zero. Run docker compose logs <service> to see the last output, or docker compose ps -a to see exit codes. Common causes: missing dependency at startup, wrong env var, healthcheck failure, or USER node not having permission to write to a mounted volume. The container is not “broken”; the process inside it is.
Do I need docker-compose.yml or compose.yaml?
Compose V2 prefers compose.yaml (or compose.yml). The hyphenated docker-compose.yml still works for backward compatibility. New projects should use compose.yaml; existing projects need not migrate.
How do I run database migrations on startup?
A separate one-shot service that runs migrations and exits, with the api depending on it via service_completed_successfully. Avoid putting migrations in the api entrypoint — running them under multiple replicas causes race conditions.
Sources and further reading
- Docker Compose documentation — the canonical reference.
- Compose file reference.
- Healthcheck syntax reference.
- Environment variables in Compose.
- Compose secrets.
- Postgres official image and Redis official image.
- Docker security best practices.
If your stack now needs to choose between Postgres and MySQL, the PostgreSQL vs MySQL guide covers the call. For the framework that will sit on the api service, the FastAPI vs Flask vs Django REST guide walks through the Python options. To wire this compose.yaml into pull-request CI, see the GitHub Actions CI/CD tutorial.