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
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
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
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(),
})
})
}
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"
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)