Docker Compose Tutorial: Run Node.js, PostgreSQL and Redis Together

Docker Compose tutorial banner showing Node.js API, PostgreSQL and Redis containers in one Compose network
Docker Compose Tutorial Banner showing a Node.js API, PostgreSQL and Redis containers connected inside one Docker Compose network, with data dots moving between them. DEVDOJO · DEVOPS Docker Compose Tutorial Run Node.js, PostgreSQL & Redis with one command $docker compose up -d ✔db, cache Healthy ✔api Started compose network apiNode.js :3000 dbPostgreSQL :5432 cacheRedis :6379

If you have ever followed a project’s README that says “install Node, then install PostgreSQL, then install Redis, then set ten environment variables”, you know how painful local setup can be. This Docker Compose tutorial fixes that. You will write one small file, run one command, and get a working Node.js API talking to a PostgreSQL database and a Redis cache — on any laptop, Windows, macOS or Linux.

Quick Summary
  • Docker Compose lets you describe many containers (API, database, cache) in one compose.yaml file and start them all with docker compose up.
  • Services talk to each other using their service names (like db and cache), not localhost.
  • Use health checks with depends_on: condition: service_healthy so your API waits until the database is really ready.
  • Use named volumes so your database data survives restarts. For PostgreSQL 18+, mount the volume at /var/lib/postgresql.
  • Use Compose Watch (docker compose watch) to see code changes live without rebuilding by hand.

What is Docker Compose? (Docker Compose tutorial basics)

Docker runs one container at a time — a small, isolated box that has your app and everything it needs. Real apps usually need more than one box: an API, a database, maybe a cache or a queue. Starting each one with a long docker run ... command, creating networks by hand and remembering the right order gets old quickly.

Docker Compose is the tool that solves this. You write a YAML file that lists your services (containers), how they are built or which image they use, which ports they open, which environment variables they need, and which data should be saved. Then a single command starts the whole stack. Think of it as a recipe card for your whole app.

One fileYour entire local setup lives in compose.yaml and is committed to Git with your code.
One commanddocker compose up builds, creates a network, and starts every service in the right order.
Same everywhereA new teammate gets the exact same PostgreSQL and Redis versions as you, in minutes.
How the services connect in Docker Compose Your browser calls localhost port 3000, which maps to the api container. The api container reaches db on port 5432 and cache on port 6379 over the private compose network. The db container stores data in a named volume. Your browser localhost:3000 default network (created by Compose) api Node.js · port 3000 db PostgreSQL · 5432 cache Redis · 6379 db-data volume port 3000 db:5432 cache:6379
Only the API is exposed to your computer. The database and cache stay private inside the Compose network and are reached by name.

Before you start

You need two things:

  • Docker Desktop (Windows/macOS) or Docker Engine with the Compose plugin (Linux). Docker Desktop already includes Compose.
  • A terminal and a code editor like VS Code.

Check that both Docker and Compose are installed:

docker --version
docker compose version
Note: The modern command is docker compose (with a space). The old docker-compose (with a hyphen) was the Python-based version 1, which is no longer maintained. Every command in this guide uses the new form.

Project structure

We will build a tiny “visit counter” API. Every time you open it, it saves a row in PostgreSQL and increases a counter in Redis. Simple, but it touches every important Compose feature. Create this folder layout:

my-app/
├── compose.yaml
├── .env
└── api/
    ├── Dockerfile
    ├── .dockerignore
    ├── package.json
    └── src/
        └── index.js

Step 1: The Node.js API

First, the package.json. We use ES modules ("type": "module"), Express 5, the pg driver for PostgreSQL and the official redis client.

api/package.json
{
  "name": "visit-counter-api",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node src/index.js",
    "dev": "node --watch src/index.js"
  },
  "dependencies": {
    "express": "^5.1.0",
    "pg": "^8.13.0",
    "redis": "^5.0.0"
  }
}

Now the API itself. Notice that it does not hard-code any hostnames or passwords. It reads DATABASE_URL and REDIS_URL from environment variables, which Compose will provide.

api/src/index.js
import express from 'express';
import pg from 'pg';
import { createClient } from 'redis';

const app = express();

// PostgreSQL connection pool (host comes from DATABASE_URL)
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });

// Redis client (host comes from REDIS_URL)
const cache = createClient({ url: process.env.REDIS_URL });
cache.on('error', (err) => console.error('Redis error:', err.message));
await cache.connect();

// Create the table once, if it does not exist yet
await pool.query(`
  CREATE TABLE IF NOT EXISTS visits (
    id SERIAL PRIMARY KEY,
    visited_at TIMESTAMPTZ NOT NULL DEFAULT now()
  )
`);

app.get('/', async (req, res) => {
  await pool.query('INSERT INTO visits DEFAULT VALUES');
  const { rows } = await pool.query('SELECT COUNT(*)::int AS total FROM visits');
  const hits = await cache.incr('hits');

  res.json({
    message: 'Hello from Docker Compose!',
    totalVisits: rows[0].total, // stored in PostgreSQL
    cacheHits: hits             // stored in Redis
  });
});

app.get('/health', (req, res) => res.json({ status: 'ok' }));

const port = process.env.PORT || 3000;
app.listen(port, () => console.log(`API running on port ${port}`));
Tip: Top-level await works here because the file is an ES module. If the database is not ready, this code would crash on startup — that is exactly why we will add health checks in Step 3.

Step 2: The Dockerfile

The Dockerfile tells Docker how to build an image for our API. We use the official node:24-alpine image (Node.js 24 is an LTS release, and Alpine keeps the image small).

api/Dockerfile
FROM node:24-alpine

WORKDIR /app

# Copy only package files first, so "npm install" is cached
COPY package*.json ./
RUN npm install --omit=dev

# Now copy the rest of the source code
COPY . .

EXPOSE 3000
CMD ["npm", "start"]

And a .dockerignore so your local node_modules and secrets never get copied into the image:

api/.dockerignore
node_modules
npm-debug.log
.env
Why copy package.json first? Docker caches each step. If you only change index.js, Docker reuses the cached npm install layer, so rebuilds take seconds instead of minutes.

Step 3: The compose.yaml file

This is the heart of the Docker Compose tutorial. First, put your settings in a .env file next to compose.yaml. Compose reads this file automatically and replaces ${VARIABLE} placeholders with these values.

.env
POSTGRES_USER=devdojo
POSTGRES_PASSWORD=change-me-please
POSTGRES_DB=appdb
Important: Add .env to your .gitignore. Commit a .env.example with dummy values instead, so teammates know which variables they need.

Now the main file:

compose.yaml
services:
  api:
    build: ./api
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
      REDIS_URL: redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_healthy
    restart: unless-stopped

  db:
    image: postgres:18-alpine
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      - db-data:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 10s

  cache:
    image: redis:8-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 10

volumes:
  db-data:

What each part means

KeyWhat it does
servicesThe list of containers. Each name (api, db, cache) also becomes a hostname on the Compose network.
build: ./apiBuild the image from the Dockerfile in the api folder.
imageUse a ready-made image from Docker Hub instead of building one.
ports: "3000:3000"HOST:CONTAINER. Opens container port 3000 on your computer’s port 3000. The database has no ports, so it stays private.
environmentEnvironment variables passed into the container.
depends_on + condition: service_healthyDo not start api until db and cache pass their health checks.
healthcheckA command Docker runs again and again to decide if the service is “healthy”.
volumesA named volume (db-data) keeps the database files even when the container is removed.
restart: unless-stoppedRestart the API automatically if it crashes, unless you stopped it yourself.
Why $$ in the health check? A single ${POSTGRES_USER} is replaced by Compose when it reads the file. Writing $${POSTGRES_USER} tells Compose “leave this alone”, so the shell inside the container reads its own environment variable instead.
PostgreSQL 18+ volume path: Starting with the PostgreSQL 18 Docker images, the data folder moved to a version-specific path, and the image’s volume is now /var/lib/postgresql. Mount your volume there (as above), not at the old /var/lib/postgresql/data. See the official postgres image page for details. Curious about what is coming next in Postgres? Read our post on PostgreSQL 19 new features.
Startup order with health checks Timeline: db and cache start first and run their health checks. Once both report healthy, Compose starts the api container, which connects successfully. 0s~5–10sready db starting… pg_isready ✗ ✗ ✗ healthy ✔ cache starting… healthy ✔ (PONG) api waiting for db + cache… started → connects on first try ✔
Without health checks, depends_on only waits for the container to start, not for PostgreSQL to accept connections.

Step 4: Run and test it

From the my-app folder, build and start everything in the background:

docker compose up -d --build

Check that all three services are running and healthy:

docker compose ps

You should see db and cache marked healthy and api marked Up. Now call the API (or open it in your browser):

curl http://localhost:3000
# {"message":"Hello from Docker Compose!","totalVisits":1,"cacheHits":1}

curl http://localhost:3000
# {"message":"Hello from Docker Compose!","totalVisits":2,"cacheHits":2}

Let’s peek inside the database and the cache directly, using docker compose exec to run commands inside running containers:

# Count rows in PostgreSQL
docker compose exec db psql -U devdojo -d appdb -c "SELECT COUNT(*) FROM visits;"

# Read the counter from Redis
docker compose exec cache redis-cli GET hits

Now stop everything and start it again:

docker compose down
docker compose up -d
curl http://localhost:3000

The totalVisits number keeps growing, because PostgreSQL data lives in the db-data volume. The Redis counter, however, starts over — we did not give Redis a volume. That is fine for a cache, and it is a good way to see the difference volumes make.

Step 5: Live reload with Compose Watch

Right now, if you edit index.js you must run docker compose up -d --build again. Compose Watch automates this: it watches files on your computer and updates the container for you. You add a develop.watch section to the service and pick an action. Click the tabs below to compare the three main actions:

Copies changed files into the running container. Best when the app already reloads itself (Node’s --watch, Vite, Next.js dev server, Nodemon). Fastest option.

  api:
    build: ./api
    command: npm run dev   # runs "node --watch"
    develop:
      watch:
        - action: sync
          path: ./api/src
          target: /app/src

Copies changed files, then restarts the container. Good when your app does not reload by itself, or for config files such as nginx.conf.

  api:
    build: ./api
    develop:
      watch:
        - action: sync+restart
          path: ./api/src
          target: /app/src

Builds a fresh image and replaces the container. Use it for changes that need a new image, like adding a package to package.json.

  api:
    build: ./api
    develop:
      watch:
        - action: rebuild
          path: ./api/package.json

In practice you combine them. Here is the api service with the recommended setup — sync source code, rebuild when dependencies change:

compose.yaml (api service)
  api:
    build: ./api
    command: npm run dev
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
      REDIS_URL: redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_healthy
    develop:
      watch:
        - action: sync
          path: ./api/src
          target: /app/src
        - action: rebuild
          path: ./api/package.json

Start it with watch mode turned on:

docker compose up --watch
# or, to only see watch events:
docker compose watch

Change the message text in index.js, save, and refresh localhost:3000. You will see the new message within a second or two — no rebuild needed. Read more in the official Compose Watch docs.

Tip: Watch mode is for development only. In production you build a fresh image once and run it — ideally in a CI pipeline. Our guide to GitHub Actions CI/CD for Node.js shows how to do that.

Everyday Docker Compose commands

You will use these commands almost every day. Bookmark this table:

CommandWhat it does
docker compose up -dStart all services in the background (“detached”).
docker compose up -d --buildRebuild images first, then start. Use after changing a Dockerfile.
docker compose psList services with their status and health.
docker compose logs -f apiFollow live logs of one service. Leave out api to see all.
docker compose exec db shOpen a shell inside a running container.
docker compose restart apiRestart just one service.
docker compose stopStop containers but keep them (fast to start again).
docker compose downStop and remove containers and the network. Volumes are kept.
docker compose down -vSame, but also deletes volumes — your database data is gone.
docker compose configPrint the final file with all ${VARIABLES} filled in. Great for debugging.

Common errors and fixes

These are the problems beginners (and plenty of seniors) hit most often:

Error / symptomWhy it happensFix
connect ECONNREFUSED 127.0.0.1:5432Your app uses localhost. Inside a container, localhost means that same container, not the database.Use the service name as host: db:5432, cache:6379.
API crashes on first start, works after restartThe API started before PostgreSQL was ready to accept connections.Add a healthcheck to db and use depends_on with condition: service_healthy.
Bind for 0.0.0.0:5432 failed: port is already allocatedAnother program (often a locally installed PostgreSQL) already uses that port on your computer.Stop the other program, or change only the host side: "5433:5432".
PostgreSQL 18 container exits with an error about the data directory or /var/lib/postgresql/dataThe volume is mounted at the old path used by images before version 18.Mount at /var/lib/postgresql. To upgrade existing data from 17, use pg_dump/restore or pg_upgrade.
the attribute `version` is obsolete warningOld tutorials start files with version: "3.8". Modern Compose ignores it.Delete the version: line.
The "POSTGRES_USER" variable is not set. Defaulting to a blank string.Compose cannot find your .env file.Put .env in the same folder as compose.yaml, or pass --env-file path/to/.env.
Changed POSTGRES_PASSWORD but login still failsThe POSTGRES_* variables are only used the first time, when the volume is empty.For local dev, reset with docker compose down -v (deletes data), or change the password with SQL.
Code changes do not show upThe image still contains the old code.Run docker compose up -d --build, or use Compose Watch.
docker-compose: command not foundYou are using the retired v1 command name.Use docker compose (with a space).

Best practices

  1. Name the file compose.yaml. It is the preferred name today. docker-compose.yml still works.
  2. Pin major versions of images (postgres:18-alpine, redis:8-alpine) instead of latest, so a surprise upgrade never breaks your setup.
  3. Expose only what you need. Leave out ports for databases unless you want to connect a GUI tool from your computer.
  4. Keep secrets out of Git. Use .env locally and a real secret manager in production.
  5. Always add health checks for databases, caches and message queues.
  6. Use named volumes for data you care about, and remember that down -v deletes them.
  7. Use a .dockerignore to keep images small and builds fast.
  8. Use profiles for optional tools. Add profiles: ["tools"] to something like pgAdmin, then start it only when needed with docker compose --profile tools up.
  9. Run docker compose config when something looks wrong — it shows exactly what Compose sees.

Here is a quick example of an optional database admin tool using a profile:

  pgadmin:
    image: dpage/pgadmin4
    profiles: ["tools"]
    ports:
      - "5050:80"
    environment:
      PGADMIN_DEFAULT_EMAIL: admin@example.com
      PGADMIN_DEFAULT_PASSWORD: admin
    depends_on:
      - db

A normal docker compose up skips it. docker compose --profile tools up -d includes it, and you can open http://localhost:5050 and connect to host db.

FAQ

What is the difference between Docker and Docker Compose?

Docker builds and runs single containers. Docker Compose is a tool on top of Docker that runs many containers together from one YAML file, with a shared network, volumes and start-up order.

Should I name my file compose.yaml or docker-compose.yml?

Both work. compose.yaml is the preferred name in the official Compose specification, so use it for new projects.

Does docker compose down delete my database?

No. down removes containers and the network but keeps named volumes. Only docker compose down -v deletes volumes (and your data).

Can I use Docker Compose in production?

Yes, for small apps on a single server it works well. For many servers, auto-scaling and zero-downtime deploys, teams usually move to Kubernetes or a managed container service. Learning Compose first makes those much easier to understand.

Why can’t my container connect to localhost?

Each container has its own network “home”. Inside the api container, localhost is the API itself. Use the other service’s name (like db) as the hostname.

Does Docker Compose work on Windows?

Yes. Install Docker Desktop (it uses WSL 2 on Windows). For the best file-watching speed, keep your project inside the WSL file system rather than on the Windows C: drive.

Conclusion

You now have a real multi-container setup: a Node.js API, a PostgreSQL database with saved data, and a Redis cache — all started with docker compose up. More importantly, you understand the ideas behind it: services talk by name, health checks control start-up order, volumes keep data safe, and Compose Watch gives you fast feedback while coding.

Try extending this project next: add a worker service that reads jobs from Redis, or a frontend service with Vite. The pattern stays exactly the same — one more block in compose.yaml.

New posts every day on DevDojo

Practical, beginner-friendly guides on AI, frontend, backend and DevOps. Bookmark devdojo.co.in and leave a comment below if you got stuck anywhere in this tutorial — we read every one.

Share