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.
# 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/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 — 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
# .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
#!/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 -dbrought 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.