Terminal.skills
Use Cases/Deploy a Full-Stack App with Docker Compose and Nginx

Deploy a Full-Stack App with Docker Compose and Nginx

Containerize a Node.js API, PostgreSQL database, and React frontend behind Nginx reverse proxy — with health checks, SSL, backups, and zero-downtime deployments on a single $20/month VPS.

DevOps#docker-compose#docker#containers#orchestration#devops
Works with:claude-codeopenai-codexgemini-clicursor
$

Rina built a project management SaaS on a $20/month Hetzner VPS. She's been deploying by SSH-ing in, pulling code, and restarting pm2 — a process that causes 30-60 seconds of downtime each time and broke twice when a Node.js version mismatch between her laptop and the server caused cryptic errors. She wants containerized, reproducible deployments with SSL, automated backups, and zero-downtime updates.

Step 1 — Define the Service Stack

The compose file defines four services: the Node.js API, PostgreSQL with persistent storage, Redis for session caching, and Nginx as a reverse proxy with SSL termination.

yaml
# compose.yml — Production service stack.
# Each service has health checks so dependents wait for readiness.
# Named volumes persist data across container restarts and updates.

services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
      target: production            # Multi-stage: only production artifacts
    environment:
      NODE_ENV: production
      DATABASE_URL: postgresql://app:${DB_PASSWORD}@db:5432/projectmgr
      REDIS_URL: redis://cache:6379
      JWT_SECRET: ${JWT_SECRET}
      PORT: "3000"
    depends_on:
      db:
        condition: service_healthy   # Wait for PostgreSQL to accept connections
      cache:
        condition: service_healthy
    restart: unless-stopped
    deploy:
      resources:
        limits:
          memory: 512M
          cpus: "1.0"
        reservations:
          memory: 256M
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/health"]
      interval: 15s
      timeout: 5s
      retries: 3
      start_period: 10s
    networks:
      - backend
    # No ports exposed — only accessible through Nginx

  db:
    image: postgres:16.2-alpine     # Pinned version for reproducibility
    environment:
      POSTGRES_DB: projectmgr
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - db-data:/var/lib/postgresql/data
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d projectmgr"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped
    deploy:
      resources:
        limits:
          memory: 1G
    networks:
      - backend
    # Port NOT exposed to host — only accessible from backend network

  cache:
    image: redis:7.2-alpine
    command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru
    volumes:
      - cache-data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 3
    restart: unless-stopped
    deploy:
      resources:
        limits:
          memory: 192M
    networks:
      - backend

  nginx:
    image: nginx:1.25-alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./frontend/dist:/usr/share/nginx/html:ro     # Pre-built React app
      - certbot-conf:/etc/letsencrypt:ro
      - certbot-www:/var/www/certbot:ro
    depends_on:
      api:
        condition: service_healthy
    restart: unless-stopped
    networks:
      - backend
      - frontend

  # Certbot for automatic SSL certificate renewal
  certbot:
    image: certbot/certbot
    volumes:
      - certbot-conf:/etc/letsencrypt
      - certbot-www:/var/www/certbot
    entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done'"

volumes:
  db-data:         # PostgreSQL data — survives container restarts
  cache-data:      # Redis persistence
  certbot-conf:    # SSL certificates
  certbot-www:     # ACME challenge files

networks:
  backend:         # API, database, cache — internal only
  frontend:        # Nginx — exposed to internet

Step 2 — Configure Nginx as Reverse Proxy

Nginx serves the React frontend as static files and proxies /api/* requests to the Node.js backend. This setup handles 10,000+ concurrent connections on a 2-core VPS — far more than Node.js could handle as a direct-facing web server.

nginx
# nginx/conf.d/app.conf — Main server configuration.
# Static frontend served from /usr/share/nginx/html.
# API requests proxied to the Node.js container.
# SSL termination with Let's Encrypt certificates.

# Rate limiting zone: 10 requests/second per IP for API endpoints
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;

# Upstream for the API — Docker DNS resolves "api" to the container IP
upstream api_backend {
    server api:3000;
    keepalive 32;     # Reuse connections to reduce TCP handshake overhead
}

# Redirect HTTP to HTTPS
server {
    listen 80;
    server_name app.rina.dev;

    # Let's Encrypt ACME challenge
    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

# Main HTTPS server
server {
    listen 443 ssl http2;
    server_name app.rina.dev;

    # SSL configuration
    ssl_certificate /etc/letsencrypt/live/app.rina.dev/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/app.rina.dev/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_stapling on;
    ssl_stapling_verify on;

    # Security headers
    add_header X-Frame-Options DENY always;
    add_header X-Content-Type-Options nosniff always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    # API proxy — all /api/* requests go to Node.js
    location /api/ {
        limit_req zone=api burst=20 nodelay;

        proxy_pass http://api_backend;
        proxy_http_version 1.1;
        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_set_header Connection "";     # Enable keepalive to upstream

        proxy_connect_timeout 10s;
        proxy_read_timeout 30s;
        proxy_send_timeout 10s;
    }

    # WebSocket support for real-time features
    location /ws {
        proxy_pass http://api_backend;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_read_timeout 86400s;     # Keep WebSocket alive for 24h
    }

    # Static frontend — React SPA
    location / {
        root /usr/share/nginx/html;
        try_files $uri $uri/ /index.html;    # SPA fallback routing

        # Cache hashed assets aggressively (filenames include content hash)
        location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
            expires 1y;
            add_header Cache-Control "public, immutable";
        }
    }

    # Block access to hidden files
    location ~ /\. {
        deny all;
        return 404;
    }

    # Gzip compression
    gzip on;
    gzip_vary on;
    gzip_min_length 1000;
    gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
}

Step 3 — Multi-Stage Dockerfile

dockerfile
# Dockerfile — Multi-stage build.
# Stage 1 (deps): install dependencies.
# Stage 2 (build): compile TypeScript.
# Stage 3 (production): minimal runtime image with only compiled code.

# --- Dependencies ---
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile

# --- Build ---
FROM deps AS build
COPY . .
RUN pnpm build
# Prune dev dependencies after build
RUN pnpm prune --prod

# --- Production ---
FROM node:22-alpine AS production
WORKDIR /app

# Security: run as non-root user
RUN addgroup -S app && adduser -S app -G app
USER app

# Copy only what's needed to run
COPY --from=build --chown=app:app /app/dist ./dist
COPY --from=build --chown=app:app /app/node_modules ./node_modules
COPY --from=build --chown=app:app /app/package.json ./

ENV NODE_ENV=production
EXPOSE 3000

CMD ["node", "dist/server.js"]

Step 4 — Automated Deployment with GitHub Actions

yaml
# .github/workflows/deploy.yml — Zero-downtime deployment.
# Builds the Docker image, pushes to GitHub Container Registry,
# then SSHs into the VPS to pull the new image and restart with rolling update.

name: Deploy
on:
  push:
    branches: [main]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    permissions:
      packages: write

    steps:
      - uses: actions/checkout@v4

      # Build frontend
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm --filter frontend build

      # Build and push API image
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: docker/build-push-action@v5
        with:
          push: true
          tags: ghcr.io/${{ github.repository }}/api:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      # Deploy to VPS
      - name: Deploy
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.VPS_HOST }}
          username: deploy
          key: ${{ secrets.VPS_SSH_KEY }}
          script: |
            cd /opt/app

            # Pull new image
            echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin
            docker pull ghcr.io/${{ github.repository }}/api:${{ github.sha }}

            # Update compose to use new image tag
            export API_IMAGE=ghcr.io/${{ github.repository }}/api:${{ github.sha }}

            # Copy updated frontend build
            rsync -a --delete frontend/dist/ /opt/app/frontend/dist/

            # Rolling restart: new container starts, old stops after health check passes
            docker compose up -d --no-deps api
            docker compose exec nginx nginx -s reload

            # Cleanup old images
            docker image prune -f

Step 5 — Automated Backups

bash
#!/bin/bash
# scripts/backup.sh — Daily PostgreSQL backup with retention.
# Dumps the database, compresses it, uploads to S3-compatible storage,
# and removes backups older than 30 days.

set -euo pipefail

TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="/opt/backups"
BACKUP_FILE="${BACKUP_DIR}/projectmgr_${TIMESTAMP}.sql.gz"
S3_BUCKET="s3://rina-backups/db"

mkdir -p "${BACKUP_DIR}"

# Dump database from the running container
docker compose exec -T db pg_dump \
  -U app \
  -d projectmgr \
  --format=custom \
  --compress=9 \
  > "${BACKUP_FILE}"

# Upload to S3-compatible storage (Backblaze B2, MinIO, AWS S3)
aws s3 cp "${BACKUP_FILE}" "${S3_BUCKET}/${TIMESTAMP}.sql.gz" \
  --endpoint-url "${S3_ENDPOINT}"

# Remove local backups older than 7 days
find "${BACKUP_DIR}" -name "*.sql.gz" -mtime +7 -delete

# Remove remote backups older than 30 days
aws s3 ls "${S3_BUCKET}/" --endpoint-url "${S3_ENDPOINT}" | \
  awk '{print $4}' | while read -r file; do
    file_date=$(echo "$file" | grep -oP '\d{8}')
    cutoff=$(date -d "30 days ago" +%Y%m%d)
    if [[ "$file_date" < "$cutoff" ]]; then
      aws s3 rm "${S3_BUCKET}/${file}" --endpoint-url "${S3_ENDPOINT}"
    fi
  done

echo "Backup completed: ${BACKUP_FILE}"

Results

Rina migrated the entire stack in one weekend. After a month of running containerized:

  • Deployment time: 5 minutes → 90 seconds — GitHub Actions builds the image in CI, pushes it, and the VPS pulls the pre-built image. No more compiling on the server.
  • Downtime per deploy: 30-60s → 0 — Docker Compose starts the new container, Nginx health-checks it, and only then stops the old one. Users don't notice deployments.
  • "Works on my machine" bugs: eliminated — the same Docker image runs locally, in CI, and in production. No more Node.js version mismatches.
  • Resource usage: ~800MB total — API container (180MB), PostgreSQL (250MB), Redis (45MB), Nginx (15MB). The $20/month 4GB VPS runs the entire stack comfortably with room for growth.
  • Backup recovery tested — Rina restored a backup to a fresh VPS in 12 minutes. The compose file and a single docker compose up -d brought everything back. No manual configuration.
  • SSL auto-renewal — Certbot renews certificates every 60 days automatically. Zero manual intervention since initial setup.
  • Response time: 120ms → 45ms — Nginx serves cached static assets directly and keeps persistent connections to the API. The API only handles dynamic requests.