Docker Compose for Full-Stack Apps: Node.js + PostgreSQL + Redis
On this page
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
- Use OrbStack instead of Docker Desktop on macOS. It is faster, uses less memory, and has better file system performance.
- Pin image versions (
postgres:16-alpine, notpostgres:latest). Reproducible builds prevent "works on my machine" issues. - Use multi-stage builds for production to keep images small. The builder stage compiles TypeScript; the runner stage only includes the compiled output.
- Add a
makeorjustfile with common commands.make up,make down,make logsare easier to remember than full Docker Compose commands. - 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_onconditions 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
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.