ZyVOP Logo
Content That Connects
SeriesAI NewsWhy ZyVOPJoin Discord
LoginGet Started
ZyVOP Logo
Content That Connects

The Developer Publishing Hub. Write once, cross-post to Dev.to, Medium, Hashnode, WordPress & Bluesky with automated canonical source tags and zero paywalls.

Content

  • Categories
  • Tags
  • Badges
  • Leaderboard
  • Write Article
  • Newsletter

Company

  • About Us
  • Why ZyVOP
  • Developer API & CLI
  • Write for Us
  • Contact

Connect

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

© 2026 ZyVOP. Developer Publishing Hub.

Zero paywalls · Full content ownership
All systems operational
HomeThe Production Docker Compose Stack: Node.js, Postgres, Redis, and Nginx in One File

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

ZyVOP
ZyVOP
Senior Developer
May 23, 2026
6 min read
The Production Docker Compose Stack: Node.js, Postgres, Redis, and Nginx in One File
#Docker secrets#internal Docker network#SSL Docker Certbot#production Docker stack 2026#postgres#Docker Compose production#Docker Compose Node.js Postgres Redis Nginx#docker-compose.override.yml
👍1

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 [email protected] \
  --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.

Comments (0)

Login to post a comment.

ZyVOP
ZyVOP

Founder of Zyvop 🚀 | Building AI-driven tools & premium insights for software engineers, CTOs, and tech leaders. Obsessed with automating workflows and exploring the frontier of AI.

Subscribe to ZyVOP's Newsletter

More from ZyVOP

View profile

Debian Adopts "Responsible Use of Generative AI" After Nine-Way Condorcet Vote

Debian's General Resolution 2026-002 closed on August 28 with "Responsible Use of Generative AI" beating eight rival proposals, including a Social Contract ban, by a clear Condorcet margin, per the project secretary's published beat matrix.

3 minAug 30

How I Built a Real-Time Developer Trend Radar Into My SEO Growth Engine

An AI-powered content intelligence system that streams live developer conversations from Hacker News, Dev.to, Google Search, and GitHub — and turns them into ready-to-write blog opportunities with one click.

12 minAug 29

Qwen3.8-Flash-Next Cost Efficiency, OpenExecutive Satire, and Multi-Vector Retrieval Advances

This week's digest covers Qwen3.8-Flash-Next's push for ultimate cost-efficiency, the viral OpenExecutive project, and the technical release of MultiVectorEncoder in Sentence-Transformers v6.0.

4 minAug 28

Anthropic’s Pricing Shock, Granite 4.2 Open‑Source Leap, and AI‑Powered Security & Policy Shifts

From Anthropic’s flagship model losing steam to IBM’s 512 K‑token Granite 4.2, plus a new wave of AI‑driven security exploits and policy alarms, this week’s digest maps the technical and market forces you need to act on now.

3 minAug 26

Introducing Questions and Discussions: A New Way to Connect!

We are thrilled to announce a major update to how you can interact and share content on our platform! Up until now, sharing your thoughts meant writing a standa...

2 minAug 1