MUHAMMAD
FARI
MADYAN
[ Press ESC or Click to Skip ]

Ruby on Rails + React E-Learning Rebuild

1

Part 1: Introduction & Architecture: Monolith to Decoupled Multithreaded Services

2

Part 2: Clean Architecture in Ruby on Rails & React: Repository Pattern, Use Cases, Result Monads & Typed Frontend Services (PR #1)

3

Part 3: High-Precision CBT Exam Engine & Real-Time Action Cable Proctoring

4

Part 4: High-Throughput Data Ingestion with Roo & DevSecOps Security Hardening

5

Part 5: Single VPS Production Deployment: Docker Compose, Puma Multithreading & Auto-Recovery

This article is available in Indonesian

🇮🇩 Baca dalam Bahasa Indonesia
🇬🇧 English📚 Ruby on Rails + React E-Learning Rebuild

Ruby on Rails 8 & React E-Learning Part 5: Single VPS Production Deployment – Docker Compose, Puma Multithreading & Auto-Recovery

Deploy a high-concurrency Ruby on Rails 8 API and React 19 platform to a single Linux VPS: multi-container Docker Compose orchestration, Puma multithreading tuning, Host Nginx reverse proxy with SSL & Action Cable WebSockets, and automated health monitoring watchdog scripts.

Muhammad Fari MadyanAuthor

8 min read

·

Oct 16, 2026


The Production Infrastructure Challenge

Deploying a modern Ruby on Rails 8 API and React 19 Single Page Application (SPA) to production presents infrastructure requirements that application code alone cannot solve:

  1. Persistent WebSocket Tunneling: Action Cable requires continuous, long-lived TCP connections. A standard reverse proxy drops idle connections after 60 seconds, which inadvertently disconnects teacher proctoring screens mid-examination.
  2. Resource Efficiency on Modest Hardware: Educational institutions typically host applications on budget-friendly virtual private servers (e.g., 2 vCPU / 4GB RAM or 4 vCPU / 8GB RAM). The stack must comfortably orchestrate Rails, Puma, PostgreSQL 16, Redis 7, and React without triggering Linux kernel Out-Of-Memory (OOM) panics.
  3. Automated Self-Healing: If an unexpected memory spike or database deadlock knocks the backend offline, the system must detect the outage and automatically restart the container within seconds while notifying the engineering team.

In mfarim/ruby-rails-elearning-react, we engineered a cost-effective, high-availability production topology on a single Linux VPS.

┌────────────────────────────────────────────────────────────────────────────────────────┐ │ SINGLE VPS PRODUCTION INFRASTRUCTURE │ └────────────────────────────────────────────────────────────────────────────────────────┘ HTTPS (:443) / WSS (:443) │ ▼ ┌──────────────────────────────────────────────────────────────────────────────────┐ │ HOST SYSTEM: NGINX REVERSE PROXY & CERTBOT SSL │ │ • Let's Encrypt TLS 1.3 Auto-Renewing Certificates │ │ • Action Cable WebSocket Tunneling (proxy_read_timeout 86400s) │ │ • Static Asset Caching with Gzip & Brotli Compression │ │ • Hardened Security Headers: HSTS, X-Frame-Options, XSS Protection │ └─────────────────────────┬───────────────────────────────┬────────────────────────┘ │ │ Proxy Pass http:// │ :3001 │ Proxy Pass http:// :8000 (Static Frontend SPA) │ │ (REST API & Action Cable) ▼ ▼ ┌──────────────────────────────────────────────────────────────────────────────────┐ │ DOCKER COMPOSE ORCHESTRATION NETWORK (bridge: elearning-network) │ │ │ │ ┌───────────────────────┐ ┌───────────────────────────────┐ │ │ │ frontend container │ │ backend container │ │ │ │ • Nginx Alpine │ │ • Ruby 3.3-slim │ │ │ │ • React 19 Build │ │ • Rails 8 Puma Server │ │ │ └───────────────────────┘ └───────────────┬───────────────┘ │ │ │ │ │ ▼ │ │ ┌───────────────────────┐ ┌───────────────────────────────┐ │ │ │ postgres container │ │ redis container │ │ │ │ • PostgreSQL 16 │ │ • Redis 7 Alpine │ │ │ │ • Healthcheck pg_isready │ • Action Cable Pub/Sub │ │ │ └───────────────────────┘ └───────────────────────────────┘ │ └──────────────────────────────────────────────────────────────────────────────────┘ ▲ │ Watchdog probes every 5 minutes via cron ┌─────────────────────────┴────────────────────────────────────────────────────────┐ │ HOST CRONJOB WATCHDOG (scripts/health-monitor.sh) │ │ • Probes GET http://localhost:8000/api/v1/health │ │ • On failure: Triggers 'docker compose restart backend' │ │ • Dispatches Discord / Telegram / Slack Outage Webhook Alert │ └──────────────────────────────────────────────────────────────────────────────────┘

1. Multi-Container Docker Compose Orchestration

In docker-compose.yml, every service is defined with health checks, restart policies, and persistent storage volumes:

YAML

# docker-compose.yml
services:
  postgres:
    image: postgres:16-alpine
    container_name: rails-elearning-postgres
    restart: always
    environment:
      POSTGRES_DB: ${POSTGRES_DB:-elearning}
      POSTGRES_USER: ${POSTGRES_USER:-postgres}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
    ports:
      - "127.0.0.1:5433:5432"  # Bind strictly to localhost
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5
    networks:
      - elearning-network

  redis:
    image: redis:7-alpine
    container_name: rails-elearning-redis
    restart: always
    ports:
      - "127.0.0.1:6381:6379"  # Bind strictly to localhost
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 5s
      retries: 5
    networks:
      - elearning-network

  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile
    container_name: rails-elearning-backend
    restart: always
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    environment:
      DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-elearning}
      REDIS_URL: redis://redis:6379/1
      SECRET_KEY: ${SECRET_KEY}
      RAILS_ENV: production
      RAILS_MAX_THREADS: 5
      WEB_CONCURRENCY: 2
    ports:
      - "127.0.0.1:8000:8000"
    volumes:
      - ./backend/storage:/app/storage
    networks:
      - elearning-network

  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile
    container_name: rails-elearning-frontend
    restart: always
    ports:
      - "127.0.0.1:3001:80"
    networks:
      - elearning-network

volumes:
  postgres_data:
  redis_data:

networks:
  elearning-network:
    driver: bridge

Production Security Safeguards:

  1. Loopback Port Isolation (127.0.0.1:5433): The database and cache services are bound strictly to 127.0.0.1, ensuring external attackers cannot probe database ports over the public internet.
  2. service_healthy Dependency Checks: The Rails backend container waits until PostgreSQL confirms readiness via pg_isready before booting, eliminating initialization race condition crashes.

2. Production Dockerfile: Lean Ruby 3.3 Slim

In backend/Dockerfile, we construct a clean image using ruby:3.3-slim:

DOCKERFILE

# backend/Dockerfile
FROM ruby:3.3-slim

WORKDIR /app

# Install system dependencies needed for compiling native gems (pg, nokogiri, roo)
RUN apt-get update -qq && apt-get install -y --no-install-recommends \
    build-essential \
    libpq-dev \
    curl \
    git \
    && rm -rf /var/lib/apt/lists/*

# Install bundler
RUN gem install bundler --no-document

# Cache gems
COPY Gemfile Gemfile.lock ./
RUN bundle config set --local without 'development test' && \
    bundle install --jobs 4 --retry 3

# Copy application code
COPY . .

# Ensure storage directories exist
RUN mkdir -p storage log tmp/pids

EXPOSE 8000

# Automated database migration and Puma startup
CMD ["bin/docker-entrypoint"]

3. Puma Web Server Concurrency Tuning

In backend/config/puma.rb, Puma is configured to leverage multithreading and multi-worker processes:

RUBY

# backend/config/puma.rb
max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }
min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count }
threads min_threads_count, max_threads_count

port ENV.fetch("PORT") { 8000 }
environment ENV.fetch("RAILS_ENV") { "production" }
pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" }

# Enable multi-worker clustered mode on multi-core servers
workers ENV.fetch("WEB_CONCURRENCY") { 2 }

# Preload app code before forking workers to maximize Copy-on-Write (CoW) RAM efficiency
preload_app!

Understanding MRI Ruby Multithreading & The GVL

A common myth in the Ruby ecosystem is that "Ruby cannot do multithreading because of the Global VM Lock (GVL)."

In an I/O-intensive web application, whenever a thread waits for a PostgreSQL query, a Redis fetch, or file disk I/O, Ruby releases the GVL. By configuring RAILS_MAX_THREADS=5, a single Puma worker process can handle 5 concurrent requests simultaneously while consuming only ~120MB of RAM—delivering over 3x higher throughput than synchronous process-per-request architectures.


4. Host Nginx Reverse Proxy with Action Cable WSS & SSL

On the host VPS, Nginx terminates SSL certificates and routes requests to the appropriate container:

NGINX

# /etc/nginx/sites-available/elearning-rails.conf

# 1. HTTP to HTTPS Redirection
server {
    listen 80;
    server_name rails-elearning.sekolah.id;
    return 301 https://$host$request_uri;
}

# 2. Production HTTPS Server
server {
    listen 443 ssl http2;
    server_name rails-elearning.sekolah.id;

    # SSL Certificates managed by Certbot
    ssl_certificate /etc/letsencrypt/live/rails-elearning.sekolah.id/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/rails-elearning.sekolah.id/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;

    # Maximum file upload limit for Excel spreadsheets and PDFs
    client_max_body_size 30M;

    # Hardened Security Headers
    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 Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    # --- REST API Proxy ---
    location /api/ {
        proxy_pass http://127.0.0.1:8000/api/;
        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;
    }

    # --- Action Cable WebSocket Tunneling ---
    location /cable {
        proxy_pass http://127.0.0.1:8000/cable;
        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;
        
        # CRITICAL: Prevent Nginx from terminating idle WebSocket proctoring streams after 60s
        proxy_read_timeout 86400s;
        proxy_send_timeout 86400s;
    }

    # --- Frontend SPA (React 19) ---
    location / {
        proxy_pass http://127.0.0.1:3001;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

The Critical Directive: proxy_read_timeout 86400s

Without explicit timeout overrides, Nginx's default 60-second read timeout will terminate idle Action Cable connections during periods of candidate inactivity. Configuring proxy_read_timeout 86400s keeps the supervision WebSocket active for the entire school testing day.


5. Automated Health Monitoring & Auto-Recovery Watchdog

To guarantee 99.9% uptime during exam hours, we implement an automated watchdog script scheduled via host cron:

BASH

#!/usr/bin/env bash
# ==============================================================================
# Production Health Monitor & Auto-Recovery Watchdog
# scripts/health-monitor.sh
# ==============================================================================
set -euo pipefail

TARGET_URL="${HEALTH_CHECK_URL:-http://localhost:8000/api/v1/health}"
WEBHOOK_URL="${ALERT_WEBHOOK_URL:-https://discord.com/api/webhooks/your_webhook_here}"
TIMEOUT_SECONDS=5
PROJECT_DIR="/var/www/ruby-rails-elearning-react"
LOG_FILE="/var/log/rails-elearning-health.log"

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}

send_alert() {
    local message="$1"
    if [ -n "$WEBHOOK_URL" ]; then
        curl -s -X POST -H "Content-Type: application/json" \
            -d "{\"content\": \"⚠️ **ALERT: Rails E-Learning System Incident**\n$message\"}" \
            "$WEBHOOK_URL" > /dev/null 2>&1 || true
    fi
}

HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time "$TIMEOUT_SECONDS" "$TARGET_URL" || echo "000")

if [ "$HTTP_STATUS" -eq 200 ]; then
    log "HEALTH OK: API returned HTTP 200"
    exit 0
else
    log "HEALTH FAILURE: Endpoint returned HTTP $HTTP_STATUS! Initiating container restart..."
    send_alert "Rails API backend returned HTTP $HTTP_STATUS at $(hostname). Auto-restarting container..."

    cd "$PROJECT_DIR"
    docker compose restart backend

    sleep 10
    RETRY_STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time "$TIMEOUT_SECONDS" "$TARGET_URL" || echo "000")

    if [ "$RETRY_STATUS" -eq 200 ]; then
        log "AUTO-RECOVERY SUCCESS: Backend restored to healthy status (HTTP 200)"
        send_alert "✅ **RECOVERY SUCCESS**: Rails API successfully restored at $(hostname)."
    else
        log "CRITICAL: Auto-recovery failed! Manual engineer intervention required."
        send_alert "🚨 **CRITICAL ALERT**: Auto-recovery failed! Backend remains down (HTTP $RETRY_STATUS)."
    fi
fi

Scheduling via Host Crontab

Configure the watchdog to probe the service every 5 minutes:

BASH

*/5 * * * * /var/www/ruby-rails-elearning-react/scripts/health-monitor.sh >> /var/log/rails-health-cron.log 2>&1

Complete Series Summary & Architectural Recap

Over the course of this 5-part series, we transitioned our monolithic legacy application into an enterprise-grade, clean-architecture Ruby on Rails 8 API:

PartTopicArchitectural Highlight
Part 1Architecture & Monolith MigrationConvention over Configuration, Puma multithreading, and full stack decoupling.
Part 2Clean Architecture & Use CasesOvercoming the fat-model dilemma, Repository Pattern, single-responsibility Use Cases, and Result Monads (PR #1).
Part 3High-Precision CBT Exam EngineServer-authoritative time calculation, 5-strike anti-cheat threshold, and Action Cable real-time proctoring.
Part 4Data Pipelines & DevSecOpsMemory-safe spreadsheet ingestion with Roo (<15MB RAM), atomic transactions, and SAST auditing with Brakeman.
Part 5Single VPS Production DeploymentMulti-container Docker Compose, Puma concurrency tuning, Nginx SSL WebSocket reverse proxying, and watchdog auto-recovery.

All source code, Docker Compose topologies, and RSpec automated test suites are open-source and available on GitHub at mfarim/ruby-rails-elearning-react.

Continue Reading

Previous article

← Previous Article

Ruby on Rails 8 & React E-Learning Part 4: High-Throughput Data Ingestion with Roo & DevSecOps Security Hardening

Next Article →

Fleet Management System Part 1: Introduction & System Architecture

Next article