← Back to Blog

Docker Compose for Local Development: Node.js + PostgreSQL + Redis from Scratch

Most developers have been here. You clone a repo, install Node, Postgres, Redis, and whatever else the README demands. Half an hour later you are debugging why the database connection is refused, or why the Redis port conflicts with something already running on your machine. Then you pull a new branch, and the cycle starts over.

Docker Compose fixes this. One file, one command, every service spins up identically on every developer's machine. No more "works on my machine."

Prerequisites

  • Docker Engine 24+ and Docker Compose v2 (check with docker version and docker compose version)
  • A Node.js project (even a fresh one works)
  • Basic terminal familiarity

Project Structure

Start with a minimal Node.js app that connects to PostgreSQL and Redis:

my-app/
  src/
    index.js
  package.json
  docker-compose.yml
  .env
  .dockerignore
{
  "name": "my-app",
  "version": "1.0.0",
  "scripts": {
    "start": "node src/index.js",
    "dev": "node --watch src/index.js"
  },
  "dependencies": {
    "express": "^4.21.0",
    "pg": "^8.13.0",
    "ioredis": "^5.4.0"
  }
}
// src/index.js
const express = require('express');
const { Pool } = require('pg');
const Redis = require('ioredis');

const app = express();
const redis = new Redis({ host: 'redis', port: 6379 });
const pool = new Pool({
  host: 'postgres',
  port: 5432,
  database: process.env.DB_NAME || 'myapp',
  user: process.env.DB_USER || 'postgres',
  password: process.env.DB_PASSWORD || 'postgres',
});

app.get('/', async (req, res) => {
  await redis.set('hits', (await redis.get('hits') || 0) + 1);
  const hits = await redis.get('hits');
  const dbResult = await pool.query('SELECT NOW()');
  res.json({
    message: 'Hello from Docker Compose!',
    hits,
    serverTime: dbResult.rows[0].now,
  });
});

app.listen(3000, () => console.log('Running on port 3000'));

Notice the database host is postgres, not localhost. Inside Docker Compose, services talk to each other by their service name. If you leave localhost here, your app will connect to itself and fail.

The docker-compose.yml

Here is the full file, then we break it down:

services:
  app:
    build: .
    ports:
      - "3000:3000"
    volumes:
      - ./src:/app/src
    environment:
      - DB_HOST=postgres
      - DB_PORT=5432
      - DB_NAME=myapp
      - DB_USER=postgres
      - DB_PASSWORD=postgres
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      - devnet

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    volumes:
      - pgdata:/var/lib/postgresql/data
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d myapp"]
      interval: 5s
      timeout: 3s
      retries: 5
    networks:
      - devnet

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5
    networks:
      - devnet

volumes:
  pgdata:

networks:
  devnet:

What each section does

app service. Builds the Dockerfile (covered below), maps port 3000, and mounts ./src into the container. That volume mount is what makes hot reload work. Edit a file locally, and the running process inside the container picks it up immediately.

postgres service. Uses the official Alpine image. The volumes section persists data between restarts, so you do not lose your database every time you run docker compose down. The healthcheck ensures the app does not try to connect before Postgres is ready.

redis service. Same pattern. Lightweight Alpine image, healthcheck with redis-cli ping.

depends_on with condition. This is the key part. Without condition: service_healthy, Docker just checks if the container started, not if the service inside is actually accepting connections. Your app would try to connect, fail, and crash.

The Dockerfile

You need a Dockerfile for the app service:

FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
CMD ["npm", "run", "dev"]

No multi-stage build here. This is local development, not production. We keep it simple. The --watch flag in the dev script restarts the Node process when files change.

Add a .dockerignore to avoid sending unnecessary files to the build context:

node_modules
npm-debug.log
.git
.env
.env.*

Environment Variables

Keep secrets out of docker-compose.yml. Use a .env file in the project root:

# .env
DB_HOST=postgres
DB_PORT=5432
DB_NAME=myapp
DB_USER=postgres
DB_PASSWORD=postgres

Docker Compose reads this file automatically and injects the values into services that reference them with ${VARIABLE_NAME} syntax. If you prefer not to use a .env file, you can set the variables inline in docker-compose.yml or export them in your shell.

Running the Stack

# Build and start everything
docker compose up --build

# Run in background
docker compose up --build -d

# View logs
docker compose logs -f app

# Stop everything
docker compose down

# Stop and remove volumes (fresh database)
docker compose down -v

Run docker compose up --build for the first time. Subsequent starts without code changes to the Dockerfile skip the build step. Adding -d runs everything in the background.

docker compose down -v wipes the database. Useful when you need a clean slate. Without -v, your data persists across restarts.

Hot Reload in Action

The volume mount ./src:/app/src is doing the heavy lifting here. When you edit src/index.js on your host machine, the change is reflected inside the container immediately. Since the dev script uses node --watch, Node detects the file change and restarts.

Try this:

  1. Start the stack with docker compose up
  2. Open src/index.js
  3. Change the response message
  4. Save the file
  5. Hit http://localhost:3000 again

The new response shows up without restarting any container. The full rebuild-and-restart cycle takes under a second.

Connecting to Services from Your Host

The ports mapping makes services accessible outside Docker. Connect to Postgres from your local pgAdmin, Tableau, or psql:

psql -h localhost -p 5432 -U postgres -d myapp

Same for Redis:

redis-cli -h localhost -p 6379

This is useful for debugging. You can inspect the database state while the app is running without installing Postgres or Redis on your machine.

Common Pitfalls

Container starts but app crashes with connection refused. The app is trying to connect to localhost or 127.0.0.1. Inside a container, localhost refers to the container itself, not the host or other containers. Use the service name (postgres, redis) as the hostname.

Port already in use. If Postgres or Redis is already running on your host, the ports mapping fails. Either stop the local service or change the host port: "5433:5432 maps the container's 5432 to your host's 5433.

Slow first build. Docker downloads the base images on the first run. Subsequent builds use the cache and start much faster.

Data disappears after docker compose down. Without a named volume (like pgdata), data is stored in the container's writable layer and gets deleted when the container is removed. The volumes section in the Postgres service prevents this.

Health check fails. If pg_isready or redis-cli ping returns an error, the dependent services never start. Check that the database name and user in the healthcheck match the POSTGRES_DB and POSTGRES_USER values.

Extending the Setup

Once the basics work, consider these additions:

  • Add a worker service (e.g., Bull queue consumer) by copying the app configuration and changing the CMD
  • Mount TLS certificates for services that need them
  • Add a reverse proxy like Nginx or Caddy in front of the app for HTTPS locally
  • Use Docker Compose profiles to optionally run extra services: docker compose --profile debug up

When to Stop Using This

Docker Compose works well for local development and simple deployments. It does not handle orchestration, auto-scaling, or rolling updates. When you need those, move to Kubernetes or a managed platform. But for daily development, Compose is hard to beat for simplicity.

References

Need Help Implementing This?

I help teams design and build scalable cloud infrastructure, DevOps pipelines, and production-grade systems.

Book a Free Consultation