ZyVOP Logo
Content That Connects
SeriesCategoriesTags
ZyVOP Logo
Content That Connects

Empowering developers and creators with cutting-edge insights, comprehensive tutorials, and innovative solutions for the digital future.

Content

  • Tags
  • Write Article

Company

  • About Us
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

© 2026 ZyVOP. Crafted with care for the developer community.

Made with ❤️ by the ZyVOP team
All systems operational
HomeThe Production Docker Compose Stack: Node.js, Postgres, Redis, and Nginx in One File
👍1

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

#Docker Compose production#Docker Compose Node.js Postgres Redis Nginx#Docker secrets#docker-compose.override.yml#production Docker stack 2026#SSL Docker Certbot#internal Docker network#postgres
Z
ZyVOP

Senior Developer

May 23, 2026
8 min read
12 views
The Production Docker Compose Stack: Node.js, Postgres, Redis, and Nginx in One File

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 overrides

The 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.txt

The 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: 512M

Getting 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 reload

After 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 -v

What 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.

Z

ZyVOP

Passionate developer sharing knowledge about modern web technologies and best practices.

Comments (0)

Login to post a comment.

Stay Updated

Get the latest articles delivered to your inbox.

We respect your privacy. Unsubscribe anytime.

Related Posts

The Ultimate Guide to Automating Postgres Backups from Docker to Google Drive

Read article

Database Replication: Why One Database Server Stops Being Enough

Read article

SQL vs NoSQL: Why Modern Systems Use Both

Read article

Popular Tags

#.env.example Node.js#0x profiling#12-factor#AI agents#AI code security#AI coding tools 2026#AI-assisted development#AI-generated vulnerabilities#ALTER TABLE no lock#API Design