~/tutorials/docker-compose-tutorial-for-beginners-node-js-postgres-redis
§ POST · MAY 12, 2026 v1.0

Docker Compose tutorial for beginners: Node.js + Postgres + Redis

A real Docker Compose tutorial: Node.js + Postgres + Redis in one docker-compose.yml, with healthchecks, volumes, .env files, and the deploy path to prod.
Ryan CallowayStaff contributor
  9 min read

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:

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

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:

  1. .env file at the project root. Loaded automatically.
  2. Inline in compose.yaml. Overrides the .env.
  3. 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

  1. 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 with condition: service_healthy as in Step 3.
  2. Exposing every service’s port to the host. Only the entry-point service (api) needs ports. 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.
  3. Anonymous volumes for data. volumes: ["/var/lib/postgresql/data"] works but you cannot tell which volume holds what; a future docker volume prune wipes it. Always name volumes (as in db-data above).
  4. Hardcoded passwords in committed files. Use .env for dev and Compose secrets or a vault for production. Audit your git history with git log -p compose.yaml on day one.
  5. Bind mount over the entire project without shielding node_modules. Mounting ./:/app overrides the image’s node_modules with the host’s empty one. Either bind-mount specific subdirs or use the anonymous-volume trick from Step 6.
  6. No restart policy on long-running services. Add restart: unless-stopped for services that should come back after a host reboot or a container crash. Default is no, 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

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.

esc