MervCodes

Tech Reviews From A Programmer

Docker Compose for Full-Stack Apps: Node.js + PostgreSQL + Redis

8 min read

Docker Compose turns a multi-service application into a single command. No more installing PostgreSQL locally, managing Redis versions, or writing setup documentation that nobody follows. This guide builds a production-ready Docker Compose setup for a Node.js API with PostgreSQL and Redis.

TL;DR: Set up a complete local development environment with Docker Compose. Includes Node.js API, PostgreSQL, Redis, hot reload, and production config.

Project Structure

my-app/
├── docker-compose.yml
├── docker-compose.prod.yml
├── Dockerfile
├── Dockerfile.dev
├── .env
├── .dockerignore
├── src/
│   ├── index.ts
│   ├── db.ts
│   └── cache.ts
├── package.json
└── tsconfig.json

Step 1: The Development Dockerfile

Create Dockerfile.dev for local development with hot reload:

FROM node:20-alpine

WORKDIR /app

# Install dependencies first (cached layer)
COPY package.json package-lock.json ./
RUN npm ci

# Copy source code
COPY . .

# Expose the API port
EXPOSE 3000

# Start with hot reload
CMD ["npx", "tsx", "watch", "src/index.ts"]

Step 2: Docker Compose for Development

# docker-compose.yml
services:
  api:
    build:
      context: .
      dockerfile: Dockerfile.dev
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgresql://postgres:postgres@db:5432/myapp
      REDIS_URL: redis://cache:6379
      NODE_ENV: development
    volumes:
      - ./src:/app/src          # Hot reload: mount source code
      - /app/node_modules       # Don't override node_modules
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    ports:
      - "5432:5432"
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: myapp
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5

  cache:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  postgres_data:
  redis_data:

Key Design Decisions

Volume mounts for hot reload: The ./src:/app/src mount syncs your source code into the container. When you save a file locally, tsx watch detects the change and restarts.

Anonymous volume for node_modules: The /app/node_modules anonymous volume prevents your local node_modules from overwriting the container's. This is critical because native modules (like bcrypt) are compiled for different architectures.

Health checks with depends_on: Using condition: service_healthy ensures the API waits for PostgreSQL and Redis to be fully ready, not just started. Without this, your API crashes on startup because the database is not accepting connections yet.

Step 3: Application Code

Database Connection

// src/db.ts
import { Pool } from 'pg';

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 20,
  idleTimeoutMillis: 30_000,
  connectionTimeoutMillis: 5_000,
});

// Verify connection on startup
pool.query('SELECT NOW()').then(() => {
  console.log('Connected to PostgreSQL');
}).catch((err) => {
  console.error('PostgreSQL connection failed:', err.message);
  process.exit(1);
});

export default pool;

Redis Connection

// src/cache.ts
import { createClient } from 'redis';

const redis = createClient({
  url: process.env.REDIS_URL,
});

redis.on('error', (err) => {
  console.error('Redis error:', err);
});

redis.on('connect', () => {
  console.log('Connected to Redis');
});

await redis.connect();

export default redis;

API Server

// src/index.ts
import express from 'express';
import pool from './db.js';
import redis from './cache.js';

const app = express();
app.use(express.json());

app.get('/health', async (req, res) => {
  try {
    await pool.query('SELECT 1');
    await redis.ping();
    res.json({ status: 'healthy', db: 'connected', cache: 'connected' });
  } catch (err) {
    res.status(500).json({ status: 'unhealthy', error: String(err) });
  }
});

app.get('/users/:id', async (req, res) => {
  const { id } = req.params;

  // Check cache first
  const cached = await redis.get(`user:${id}`);
  if (cached) {
    return res.json({ ...JSON.parse(cached), source: 'cache' });
  }

  // Query database
  const result = await pool.query('SELECT * FROM users WHERE id = $1', [id]);
  if (result.rows.length === 0) {
    return res.status(404).json({ error: 'User not found' });
  }

  const user = result.rows[0];

  // Cache for 5 minutes
  await redis.set(`user:${id}`, JSON.stringify(user), { EX: 300 });

  res.json({ ...user, source: 'database' });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`API server running on port ${PORT}`);
});

Step 4: Database Initialization

Create init.sql for initial schema setup:

-- init.sql
CREATE TABLE IF NOT EXISTS users (
  id SERIAL PRIMARY KEY,
  name VARCHAR(255) NOT NULL,
  email VARCHAR(255) UNIQUE NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

INSERT INTO users (name, email) VALUES
  ('Alice', '[email protected]'),
  ('Bob', '[email protected]')
ON CONFLICT DO NOTHING;

This file runs automatically when PostgreSQL initializes for the first time (when the postgres_data volume is empty).

Step 5: Essential Docker Ignore

Create .dockerignore to keep images small and builds fast:

node_modules
.git
.env
*.md
dist
coverage
.next

Step 6: Running It

# Start all services
docker compose up

# Start in detached mode (background)
docker compose up -d

# View logs
docker compose logs -f api

# Stop all services
docker compose down

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

# Rebuild after changing Dockerfile
docker compose up --build

Step 7: Production Configuration

Create a separate Dockerfile for production:

# Dockerfile (production)
FROM node:20-alpine AS builder

WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine AS runner

WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json /app/package-lock.json ./
RUN npm ci --omit=dev

USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]

And a production compose override:

# docker-compose.prod.yml
services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
    environment:
      NODE_ENV: production
    volumes: [] # No source mounts in production
    deploy:
      replicas: 2
      resources:
        limits:
          memory: 512M
          cpus: "0.5"
        reservations:
          memory: 256M
          cpus: "0.25"

  db:
    environment:
      POSTGRES_PASSWORD: ${DB_PASSWORD} # From .env file
    volumes:
      - postgres_data:/var/lib/postgresql/data
      # No init.sql mount — use migrations instead

  cache:
    command: redis-server --requirepass ${REDIS_PASSWORD}

Deploy with:

docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

Useful Commands

# Execute a command in a running container
docker compose exec db psql -U postgres myapp

# Run a one-off migration
docker compose exec api npx prisma migrate deploy

# Check resource usage
docker compose stats

# Scale a specific service
docker compose up -d --scale api=3

# View container details
docker compose ps

Common Pitfalls

"Connection refused" on startup: Your API starts before PostgreSQL is ready. Use healthcheck + depends_on.condition as shown above.

Changes to node_modules not reflected: After adding a dependency, rebuild the container: docker compose up --build api.

PostgreSQL data persists after schema changes: The init script only runs on first initialization. Either drop the volume (docker compose down -v) or use a migration tool like Prisma or Knex.

Slow builds on macOS: Docker's file system performance on macOS is slower than Linux. Use :cached or :delegated mount options, or switch to OrbStack for significantly better performance.

Pro Tips

  1. Use OrbStack instead of Docker Desktop on macOS. It is faster, uses less memory, and has better file system performance.
  2. Pin image versions (postgres:16-alpine, not postgres:latest). Reproducible builds prevent "works on my machine" issues.
  3. Use multi-stage builds for production to keep images small. The builder stage compiles TypeScript; the runner stage only includes the compiled output.
  4. Add a make or just file with common commands. make up, make down, make logs are easier to remember than full Docker Compose commands.
  5. Use named volumes for database data. Anonymous volumes are harder to manage and back up.

Key Takeaways

  • Docker Compose replaces manual service installation with a single docker compose up
  • Use health checks and depends_on conditions to prevent startup race conditions
  • Mount source code for hot reload in development, but not in production
  • Use multi-stage builds to keep production images small and secure
  • Pin your image versions for reproducible builds

Sources

  1. Docker Documentation
  2. Docker Hub
  3. Docker Compose Documentation

Looking for more? Check out Adaptels.

Related Articles

How to Debug Node.js Memory Leaks (Step-by-Step Guide)

Learn how to detect, diagnose, and fix Node.js memory leaks using heap snapshots, Chrome DevTools, and clinic.js — with real code examples.

How to Set Up GitHub Actions for CI/CD (Beginner-Friendly Guide)

Learn how to set up GitHub Actions for CI/CD pipelines — from your first workflow file to automated deployments with real YAML examples.

Running Local LLMs With Ollama: Developer Setup Guide

Set up Ollama to run local LLMs on your machine. Covers installation, model selection, API usage, and integrating local models into your dev workflow.