DEV Community

Anup Karanjkar
Anup Karanjkar

Posted on • Originally published at wowhow.cloud

Docker Compose for Production: Node.js + Redis + PostgreSQL Stack

Docker Compose is excellent for production when you use it correctly. The common failure modes — services starting before their dependencies are ready, secrets in environment variables visible in docker inspect, single-layer images that are 1.2GB, no memory limits causing OOM kills, volumes with no backup strategy — are all avoidable with the right configuration.

This guide walks through a complete production-grade Node.js + Redis + PostgreSQL stack. Every configuration choice here addresses a real failure mode.

Try it yourself: Free .env File Converter — free, no signup, runs in your browser.

Multi-Stage Dockerfile: Node.js

A naive Node.js Dockerfile copies everything and installs all dependencies in one layer. The result is a 1GB+ image with dev dependencies, source maps, and unnecessary files. Multi-stage builds produce images under 200MB with only what production needs.

# Dockerfile
# Stage 1: Install all dependencies (including dev)
FROM node:22-alpine AS deps
WORKDIR /app

# Copy package files first for layer caching
COPY package*.json ./
COPY prisma ./prisma/

# Install all deps including devDeps for build
RUN npm ci --frozen-lockfile

# Stage 2: Build the application
FROM node:22-alpine AS builder
WORKDIR /app

COPY --from=deps /app/node_modules ./node_modules
COPY . .

# Generate Prisma client
RUN npx prisma generate

# Build TypeScript
RUN npm run build

# Stage 3: Production image — only runtime artifacts
FROM node:22-alpine AS runner
WORKDIR /app

ENV NODE_ENV=production

# Security: run as non-root
RUN addgroup --system --gid 1001 nodejs &&     adduser --system --uid 1001 --ingroup nodejs nodeuser

# Copy only what production needs
COPY --from=builder --chown=nodeuser:nodejs /app/dist ./dist
COPY --from=builder --chown=nodeuser:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodeuser:nodejs /app/prisma ./prisma
COPY --chown=nodeuser:nodejs package.json ./

USER nodeuser

EXPOSE 3000

# Use exec form to handle signals properly (not shell form)
CMD ["node", "dist/server.js"]

HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=3   CMD wget -qO /dev/null http://localhost:3000/health || exit 1
Enter fullscreen mode Exit fullscreen mode

Complete docker-compose.yml for Production

# docker-compose.yml
name: myapp

services:
  # --- PostgreSQL ---
  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${POSTGRES_DB:-myapp}
      POSTGRES_USER: ${POSTGRES_USER:-myapp}
      POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
    secrets:
      - postgres_password
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./db/init:/docker-entrypoint-initdb.d:ro
    networks:
      - backend
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-myapp} -d ${POSTGRES_DB:-myapp}"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M
        reservations:
          memory: 256M

  # --- Redis ---
  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: >
      redis-server
      --requirepass-file /run/secrets/redis_password
      --maxmemory 256mb
      --maxmemory-policy allkeys-lru
      --save 60 1000
      --appendonly yes
    secrets:
      - redis_password
    volumes:
      - redis_data:/data
    networks:
      - backend
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "$(cat /run/secrets/redis_password)", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 15s
    deploy:
      resources:
        limits:
          cpus: "0.5"
          memory: 384M

  # --- Database migrations (run-once job) ---
  migrator:
    build:
      context: .
      dockerfile: Dockerfile
      target: builder
    command: npx prisma migrate deploy
    environment:
      DATABASE_URL: "postgresql://${POSTGRES_USER:-myapp}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-myapp}"
    secrets:
      - postgres_password
    depends_on:
      postgres:
        condition: service_healthy
    networks:
      - backend
    restart: "no"

  # --- Application ---
  app:
    build:
      context: .
      dockerfile: Dockerfile
      target: runner
      cache_from:
        - type=registry,ref=myregistry.io/myapp:buildcache
      cache_to:
        - type=registry,ref=myregistry.io/myapp:buildcache,mode=max
    image: myregistry.io/myapp:latest
    restart: unless-stopped
    environment:
      NODE_ENV: production
      PORT: "3000"
      DATABASE_URL_FILE: /run/secrets/database_url
      REDIS_URL_FILE: /run/secrets/redis_url
      JWT_SECRET_FILE: /run/secrets/jwt_secret
    secrets:
      - database_url
      - redis_url
      - jwt_secret
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
      migrator:
        condition: service_completed_successfully
    networks:
      - backend
      - frontend
    healthcheck:
      test: ["CMD", "wget", "-qO", "/dev/null", "http://localhost:3000/health"]
      interval: 15s
      timeout: 5s
      retries: 3
      start_period: 30s
    deploy:
      replicas: 2
      update_config:
        parallelism: 1
        delay: 15s
        failure_action: rollback
        order: start-first
      rollback_config:
        parallelism: 1
        delay: 10s
      resources:
        limits:
          cpus: "1.0"
          memory: 512M
        reservations:
          cpus: "0.25"
          memory: 128M
    logging:
      driver: "json-file"
      options:
        max-size: "50m"
        max-file: "5"

  # --- Nginx reverse proxy ---
  nginx:
    image: nginx:1.25-alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - certbot_www:/var/www/certbot:ro
      - certbot_conf:/etc/letsencrypt:ro
    depends_on:
      app:
        condition: service_healthy
    networks:
      - frontend
    healthcheck:
      test: ["CMD", "nginx", "-t"]
      interval: 30s
      timeout: 10s

volumes:
  postgres_data:
    driver: local
  redis_data:
    driver: local
  certbot_www:
  certbot_conf:

networks:
  backend:
    driver: bridge
    internal: true   # No direct internet access
  frontend:
    driver: bridge

secrets:
  postgres_password:
    file: ./secrets/postgres_password.txt
  redis_password:
    file: ./secrets/redis_password.txt
  database_url:
    file: ./secrets/database_url.txt
  redis_url:
    file: ./secrets/redis_url.txt
  jwt_secret:
    file: ./secrets/jwt_secret.txt
Enter fullscreen mode Exit fullscreen mode

Reading Docker Secrets in Node.js

Docker secrets are mounted as files under /run/secrets/. Reading them at startup prevents secrets from appearing in docker inspect environment variables.

// src/config/secrets.ts
import { readFileSync } from 'fs'

function readSecret(envVar: string, secretFile?: string): string {
  // First try the _FILE variant (Docker secrets pattern)
  const fileEnvVar = `${envVar}_FILE`
  const filePath = process.env[fileEnvVar] ?? secretFile

  if (filePath) {
    try {
      return readFileSync(filePath, 'utf-8').trim()
    } catch (err) {
      // Fall through to direct env var
    }
  }

  const value = process.env[envVar]
  if (!value) {
    throw new Error(`Required secret '${envVar}' is not set (checked ${fileEnvVar} and ${envVar})`)
  }
  return value
}

export const secrets = {
  databaseUrl: readSecret('DATABASE_URL'),
  redisUrl: readSecret('REDIS_URL'),
  jwtSecret: readSecret('JWT_SECRET'),
} as const
Enter fullscreen mode Exit fullscreen mode

Health Check Endpoint

// src/routes/health.ts
import type { FastifyInstance } from 'fastify'
import { prisma } from '../db/prisma'
import { redis } from '../cache/redis'

interface HealthStatus {
  status: 'ok' | 'degraded' | 'down'
  checks: Record
  uptime: number
  timestamp: string
}

export async function healthRoutes(app: FastifyInstance): Promise {
  app.get('/health', { logLevel: 'silent' }, async (_req, reply) => {
    const checks: HealthStatus['checks'] = {}

    // Check PostgreSQL
    const pgStart = Date.now()
    try {
      await prisma.$queryRaw`SELECT 1`
      checks.postgres = { status: 'ok', latencyMs: Date.now() - pgStart }
    } catch (err) {
      checks.postgres = { status: 'fail', latencyMs: Date.now() - pgStart, error: String(err) }
    }

    // Check Redis
    const redisStart = Date.now()
    try {
      await redis.ping()
      checks.redis = { status: 'ok', latencyMs: Date.now() - redisStart }
    } catch (err) {
      checks.redis = { status: 'fail', latencyMs: Date.now() - redisStart, error: String(err) }
    }

    const allOk = Object.values(checks).every((c) => c.status === 'ok')
    const anyFail = Object.values(checks).some((c) => c.status === 'fail')

    const status: HealthStatus['status'] = allOk ? 'ok' : anyFail ? 'degraded' : 'down'
    const httpStatus = status === 'ok' ? 200 : status === 'degraded' ? 200 : 503

    return reply.status(httpStatus).send({
      status,
      checks,
      uptime: process.uptime(),
      timestamp: new Date().toISOString(),
    })
  })
}
Enter fullscreen mode Exit fullscreen mode

Backup Script for PostgreSQL Volume

#!/bin/bash
# scripts/backup-postgres.sh
set -euo pipefail

BACKUP_DIR="/backups/postgres"
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="${BACKUP_DIR}/dump_${DATE}.sql.gz"
RETENTION_DAYS=7

mkdir -p "${BACKUP_DIR}"

echo "[$(date)] Starting PostgreSQL backup..."

docker exec myapp-postgres-1   pg_dump -U "${POSTGRES_USER:-myapp}" "${POSTGRES_DB:-myapp}" |   gzip > "${BACKUP_FILE}"

echo "[$(date)] Backup written to ${BACKUP_FILE} ($(du -sh "${BACKUP_FILE}" | cut -f1))"

# Delete backups older than retention period
find "${BACKUP_DIR}" -name "dump_*.sql.gz" -mtime +"${RETENTION_DAYS}" -delete
echo "[$(date)] Cleaned backups older than ${RETENTION_DAYS} days"
Enter fullscreen mode Exit fullscreen mode

People Also Ask

Is Docker Compose suitable for production deployments?

Yes, for single-node or small-scale deployments. Docker Compose with the deploy config block supports replicas, update strategies, rollback, and resource limits. For multi-node orchestration, Kubernetes or Docker Swarm is more appropriate. Many production workloads at moderate scale run fine on a single well-configured Docker Compose stack with proper health checks and restart policies.

How do I pass secrets to Docker Compose without using environment variables?

Use Docker secrets — mount files under /run/secrets/ and read them at startup. In Compose, define secrets as file: references or use external secrets from Vault/AWS Secrets Manager. Reading from files prevents secrets from appearing in docker inspect, process lists, or log output. Never put passwords directly in the environment: block.

Why does my service start before the database is ready?

depends_on alone only waits for the container to start, not for the service inside to be ready. Use depends_on: condition: service_healthy combined with a proper healthcheck on the dependency. The PostgreSQL pg_isready healthcheck and the Redis ping healthcheck shown above are the standard patterns.

Ready-to-use Docker Compose templates, Node.js starter kits, and production boilerplates are available at WOWHOW developer tools. Browse infrastructure and DevOps resources at the full catalog.

Originally published at wowhow.cloud

Top comments (0)