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:
- 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.
- 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.
- 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.
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:
- Loopback Port Isolation (
127.0.0.1:5433): The database and cache services are bound strictly to127.0.0.1, ensuring external attackers cannot probe database ports over the public internet. service_healthyDependency Checks: The Rails backend container waits until PostgreSQL confirms readiness viapg_isreadybefore 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:
| Part | Topic | Architectural Highlight |
|---|---|---|
| Part 1 | Architecture & Monolith Migration | Convention over Configuration, Puma multithreading, and full stack decoupling. |
| Part 2 | Clean Architecture & Use Cases | Overcoming the fat-model dilemma, Repository Pattern, single-responsibility Use Cases, and Result Monads (PR #1). |
| Part 3 | High-Precision CBT Exam Engine | Server-authoritative time calculation, 5-strike anti-cheat threshold, and Action Cable real-time proctoring. |
| Part 4 | Data Pipelines & DevSecOps | Memory-safe spreadsheet ingestion with Roo (<15MB RAM), atomic transactions, and SAST auditing with Brakeman. |
| Part 5 | Single VPS Production Deployment | Multi-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.

