The Production Docker Compose Stack: Node.js, Postgres, Redis, and Nginx in One File
The base / override / prod split pattern, Docker secrets, internal networks, and automatic SSL — the full stack done properly
Senior Developer

A lot of Docker Compose tutorials show you how to run two containers locally. This is not that. This is the full production-grade stack — Node.js app, Postgres, Redis, Nginx, health checks, named volumes, internal networks, secrets, and an override file that keeps your local dev setup clean.
One docker-compose.yml. One docker-compose.override.yml for dev. A compose.prod.yml for prod. That is the pattern.
The Goal
By the end of this guide you will have:
A Node.js app served over HTTPS with automatic certificate renewal
Postgres with persistent data and a health check
Redis with persistence and password protection
Nginx as the front door handling SSL termination
Separate internal networks so your database is never exposed to the internet
Secrets managed via files, not environment variables
A dev override that adds hot-reloading without touching your production config
Project Structure
/project
├── app/
│ ├── src/
│ ├── Dockerfile
│ └── package.json
├── nginx/
│ ├── nginx.conf
│ └── conf.d/
│ └── app.conf
├── secrets/
│ ├── db_password.txt
│ └── redis_password.txt
├── .env # Non-secret config (port numbers, names)
├── .env.example # Committed to git — template only
├── docker-compose.yml # Base config — works for both dev and prod
├── docker-compose.override.yml # Dev extras (auto-loaded locally)
└── docker-compose.prod.yml # Production overridesThe secrets/ directory holds one-line password files. It is in .gitignore. Never commit it.
The .env File
# .env (not committed — copy from .env.example and fill in)
# App
NODE_ENV=production
PORT=3000
APP_IMAGE=ghcr.io/your-org/your-app:latest
# Database
POSTGRES_DB=appdb
POSTGRES_USER=appuser
POSTGRES_HOST=db
POSTGRES_PORT=5432
# Redis
REDIS_HOST=redis
REDIS_PORT=6379
# Domain
DOMAIN=yourdomain.com# .env.example (committed — shows required variables without values)
NODE_ENV=
PORT=
APP_IMAGE=
POSTGRES_DB=
POSTGRES_USER=
POSTGRES_HOST=
POSTGRES_PORT=
REDIS_HOST=
REDIS_PORT=
DOMAIN=The Base docker-compose.yml
# docker-compose.yml
version: "3.8"
services:
# ────────────────────────────────────────────
# Application
# ────────────────────────────────────────────
app:
image: ${APP_IMAGE}
container_name: app
restart: unless-stopped
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
environment:
NODE_ENV: ${NODE_ENV}
PORT: ${PORT}
DATABASE_URL: postgresql://${POSTGRES_USER}@db:${POSTGRES_PORT}/${POSTGRES_DB}
REDIS_URL: redis://:@redis:${REDIS_PORT}
secrets:
- db_password
- redis_password
networks:
- frontend
- backend
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:${PORT}/health', r => process.exit(r.statusCode === 200 ? 0 : 1))"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ────────────────────────────────────────────
# PostgreSQL
# ────────────────────────────────────────────
db:
image: postgres:16-alpine
container_name: db
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
volumes:
- postgres_data:/var/lib/postgresql/data
- ./db/init:/docker-entrypoint-initdb.d # SQL files run on first start
networks:
- backend
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ────────────────────────────────────────────
# Redis
# ────────────────────────────────────────────
redis:
image: redis:7-alpine
container_name: redis
restart: unless-stopped
command: >
sh -c "redis-server
--requirepass $$(cat /run/secrets/redis_password)
--appendonly yes
--maxmemory 256mb
--maxmemory-policy allkeys-lru"
secrets:
- redis_password
volumes:
- redis_data:/data
networks:
- backend
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ────────────────────────────────────────────
# Nginx
# ────────────────────────────────────────────
nginx:
image: nginx:alpine
container_name: nginx
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_webroot:/var/www/certbot:ro # For Let's Encrypt challenge
- certbot_certs:/etc/letsencrypt:ro # SSL certificates
depends_on:
- app
networks:
- frontend
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost/health"]
interval: 30s
timeout: 10s
retries: 3
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ────────────────────────────────────────────
# Certbot (SSL certificate renewal)
# ────────────────────────────────────────────
certbot:
image: certbot/certbot:latest
container_name: certbot
volumes:
- certbot_webroot:/var/www/certbot
- certbot_certs:/etc/letsencrypt
# Renews certificates every 12 hours; exits if nothing to renew
entrypoint: >
/bin/sh -c "trap exit TERM;
while :; do
certbot renew --webroot -w /var/www/certbot --quiet;
sleep 12h & wait $${!};
done"
# ────────────────────────────────────────────
# Named Volumes
# ────────────────────────────────────────────
volumes:
postgres_data:
driver: local
redis_data:
driver: local
certbot_webroot:
driver: local
certbot_certs:
driver: local
# ────────────────────────────────────────────
# Networks
# ────────────────────────────────────────────
networks:
frontend:
# App and Nginx only — web-facing
backend:
internal: true # Cannot reach the internet — db and redis are isolated here
# App, db, and redis
# ────────────────────────────────────────────
# Secrets
# ────────────────────────────────────────────
secrets:
db_password:
file: ./secrets/db_password.txt
redis_password:
file: ./secrets/redis_password.txtThe internal: true on the backend network is the key security detail. Postgres and Redis containers on that network cannot make outbound internet connections. If a container is compromised, the blast radius is contained.
Nginx Config
nginx/conf.d/app.conf:
# HTTP — redirect to HTTPS and serve Let's Encrypt challenge
server {
listen 80;
server_name yourdomain.com www.yourdomain.com;
# Let's Encrypt ACME challenge
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS
server {
listen 443 ssl;
server_name yourdomain.com www.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Gzip
gzip on;
gzip_types application/json text/plain text/css application/javascript;
gzip_min_length 1000;
# Proxy to app container
location / {
proxy_pass http://app:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 60s;
}
}The Dev Override
docker-compose.override.yml (auto-loaded when you run docker compose up locally):
# docker-compose.override.yml
# Only loaded in development. Not used in production.
version: "3.8"
services:
app:
# Build locally instead of pulling from registry
build:
context: ./app
target: development
# Mount source code for hot-reloading
volumes:
- ./app/src:/app/src
environment:
NODE_ENV: development
# Expose app port directly for debugging (no Nginx in dev)
ports:
- "3000:3000"
db:
# Expose Postgres port locally for direct access (pgAdmin, psql, etc.)
ports:
- "5432:5432"
redis:
# Expose Redis port locally for debugging
ports:
- "6379:6379"
# Remove Nginx and Certbot in dev — unnecessary complexity
nginx:
profiles: ["prod"] # Only starts when --profile prod is passed
certbot:
profiles: ["prod"]In development: docker compose up — loads base + override, no Nginx. In production: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
The Production Override
docker-compose.prod.yml:
version: "3.8"
services:
app:
# Use the published image, not a local build
image: ${APP_IMAGE}
deploy:
resources:
limits:
memory: 512M
cpus: "0.5"
db:
deploy:
resources:
limits:
memory: 512MGetting SSL Certificates (First Time)
SSL requires a chicken-and-egg setup. Nginx needs certificates to start, but Certbot needs Nginx to be running to complete the ACME challenge. The solution: start Nginx with a self-signed cert, get the real cert, then reload.
# 1. Create the secret files
mkdir secrets
echo "yourStrongDbPassword" > secrets/db_password.txt
echo "yourStrongRedisPassword" > secrets/redis_password.txt
chmod 600 secrets/*.txt
# 2. Start with a temporary self-signed cert (just to get Nginx running)
mkdir -p nginx/certs
openssl req -x509 -nodes -newkey rsa:4096 \
-keyout nginx/certs/privkey.pem \
-out nginx/certs/fullchain.pem \
-days 1 -subj "/CN=localhost"
# 3. Start the stack
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
# 4. Get the real certificate
docker compose run --rm certbot certonly \
--webroot -w /var/www/certbot \
--email you@yourdomain.com \
--agree-tos --no-eff-email \
-d yourdomain.com -d www.yourdomain.com
# 5. Reload Nginx to pick up the real cert
docker compose exec nginx nginx -s reloadAfter this, the Certbot container handles renewal automatically every 12 hours.
Useful Day-to-Day Commands
# Start the production stack (detached)
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
# Check container status and health
docker compose ps
# Follow logs for a specific service
docker compose logs -f app
# Run a database migration
docker compose exec app npm run migrate
# Open a psql shell
docker compose exec db psql -U appuser -d appdb
# Open a Redis shell
docker compose exec redis redis-cli -a $(cat secrets/redis_password.txt)
# Restart a single service without affecting others
docker compose restart app
# Pull latest images and redeploy
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --no-deps app
# Stop everything
docker compose down
# Stop and wipe all volumes (DESTRUCTIVE — deletes database data)
docker compose down -vWhat This Pattern Gets You
The split between base, dev override, and prod override is the pattern that scales. Your production config is never contaminated with dev conveniences like exposed database ports. Your dev setup is never slowed down by Nginx and Certbot overhead. Both environments share the same base service definitions so they stay in sync.
The internal network means your database and Redis are genuinely not accessible from outside the stack — not just by convention, but by network policy. The secrets pattern means passwords are never in environment variables, never in your logs, and never in your git history.
Run it, break it once, understand what each part does, and you will have the infrastructure foundation for almost any backend you build.
Comments (0)
Login to post a comment.