MUHAMMAD
FARI
MADYAN
[ Press ESC or Click to Skip ]

Java Spring Boot + React E-Learning Rebuild

1

Part 1: Introduction & Architecture: Monolith to Decoupled Enterprise

2

Part 2: Production Readiness & Security Hardening: Path Traversal, Refresh Tokens & Docker Resilience

3

Part 3: Single VPS Production Deployment: Docker Compose, Host Nginx Reverse Proxy & SSL Let's Encrypt

This article is available in Indonesian

🇮🇩 Baca dalam Bahasa Indonesia
🇬🇧 English📚 Java Spring Boot + React E-Learning Rebuild

Java Spring Boot + React E-Learning: Single VPS Production Deployment with Docker Compose, Host Nginx Reverse Proxy & SSL Let's Encrypt

A battle-tested, step-by-step production deployment guide for the Java 21 Spring Boot 3 & React 19 E-Learning platform on a single Ubuntu VPS: Docker Compose production topology, Host Nginx SSL termination with Certbot, Swap memory tuning, WebSocket proxying, automated daily database backups, and zero-downtime redeployment scripts.

Muhammad Fari MadyanAuthor

11 min read

·

Oct 13, 2026


Introduction: Moving from Local Containers to Production Reality

In Part 2 of this series, we hardened our open-source companion platform—mfarim/spring-elearning-react—against path traversal vulnerabilities, implemented seamless refresh token rotation, and added Spring Boot Actuator health checks.

Now comes the ultimate operational milestone: taking the system live on a real public server.

For most independent schools, colleges, and startups, running a multi-cluster Kubernetes setup or multiple managed AWS instances ($150–$300/month) is overkill and cost-prohibitive. Instead, a well-tuned Single Virtual Private Server (VPS) costing $6–$15/month can comfortably serve thousands of examination candidates when architected with proper container isolation, reverse proxying, and caching.

In this comprehensive guide, we walk through the exact, battle-tested deployment blueprint used to deploy Spring E-Learning & CBT onto a single Ubuntu VPS under the dedicated production subdomain: 👉 https://spring-elearning.mfarim.com

┌─────────────────────────────────────────────────────────────────────────────┐ │ PRODUCTION DEPLOYMENT OVERVIEW │ ├──────────────────────────────────────┬──────────────────────────────────────┤ │ Target Environment │ Single Ubuntu VPS (22.04 / 24.04 LTS)│ │ Production URL │ https://spring-elearning.mfarim.com │ │ Backend Service │ Java 21 LTS + Spring Boot 3.4.3 API │ │ Frontend Service │ React 19 SPA + Vite 6 + Tailwind v4 │ │ Container Topology │ Docker Compose Production (4 Pods) │ │ Reverse Proxy & SSL │ Host Nginx + Let's Encrypt (Certbot) │ │ Internal Port Mapping │ 127.0.0.1:8088 -> Container Port 80 │ │ Database & Cache Isolation │ PostgreSQL 16 & Redis 7 (Internal) │ │ Data Persistence │ Docker Named Volumes (DB & Storage) │ │ Backup & Maintenance │ Automated Daily Cronjob (pg_dump) │ └──────────────────────────────────────┴──────────────────────────────────────┘

1. Single VPS Deployment Architecture

On a shared or single VPS, Nginx installed on the host operating system controls ports 80 (HTTP) and 443 (HTTPS), handles TLS termination via Let's Encrypt, and forwards requests internally to Docker on a dedicated loopback port (127.0.0.1:8088):

┌─────────────────────────────────────────────────────────────────────────────┐ │ HOST OPERATING SYSTEM (VPS) │ │ │ │ Public Internet │ │ │ │ │ HTTPS :443 (spring-elearning.mfarim.com) │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────────────┐ │ │ │ HOST NGINX REVERSE PROXY │ │ │ │ • SSL Termination via Let's Encrypt (Certbot) │ │ │ │ • WebSocket Upgrade Headers (HTTP/1.1) │ │ │ │ • Request Payload Buffering (client_max_body_size 50M) │ │ │ └──────────────────────────────────┬──────────────────────────────────┘ │ │ │ │ │ Proxy Pass to │ │ 127.0.0.1:8088 │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────────────┐ │ │ │ DOCKER CONTAINER NETWORK │ │ │ │ │ │ │ │ ┌──────────────────────┐ ┌──────────────────────────┐ │ │ │ │ │ elearning-frontend │ │ elearning-backend │ │ │ │ │ │ (Nginx + React 19) │──/api/*──▶│ (Spring Boot 21) │ │ │ │ │ │ Listening on :8088 │──/ws/*───▶│ Port 8080 (Internal) │ │ │ │ │ └──────────────────────┘ └────────────┬─────────────┘ │ │ │ │ │ │ │ │ │ ┌────────────────────────────┼───────────┐ │ │ │ │ ▼ ▼ ▼ │ │ │ │ ┌───────────────────┐ ┌───────────────┐ ┌───────┐ │ │ │ │ │ elearning-postgres│ │elearning-redis│ │Storage│ │ │ │ │ │ PostgreSQL 16 │ │ Redis 7 │ │Volume │ │ │ │ │ │ (Port 5432 Int.) │ │(Port 6379 Int)│ │/upload│ │ │ │ │ └───────────────────┘ └───────────────┘ └───────┘ │ │ │ └─────────────────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────────────────┘

Key Architectural Advantages:

  1. Coexistence with Existing Websites: Ports 80 and 443 remain with the Host Nginx, allowing you to host your personal portfolio, blog (mfarim.com), or other APIs side-by-side on the same VPS without port collisions.
  2. Database Isolation: PostgreSQL and Redis containers do not expose ports to the public internet (0.0.0.0), preventing brute-force database attacks.
  3. Zero Data Loss on Redeploy: All uploaded PDFs, question images, and relational tables reside in Docker named volumes (postgres_data, redis_data, backend_storage).

2. Server Sizing & Initial Preparation

Minimum Recommended Hardware Specifications:

  • Operating System: Ubuntu 22.04 LTS or 24.04 LTS (64-bit)
  • CPU: 2 vCPUs minimum
  • RAM: 2 GB minimum (Important: 2 GB RAM must have 2–4 GB Swap enabled to prevent the Linux OOM Killer from terminating Maven or JVM processes during compilation). Recommended: 4 GB RAM.
  • Storage: 25 GB+ SSD/NVMe

Step 1: System Update & Swap Memory Configuration

Connect to your VPS via SSH:

BASH

ssh root@<YOUR_VPS_PUBLIC_IP>

Update system packages and install basic utilities:

BASH

sudo apt update && sudo apt upgrade -y
sudo apt install -y curl wget git ufw htop ca-certificates gnupg lsb-release

Allocating Swap Memory (Critical for JVM on 2 GB RAM):

Compiling TypeScript in Vite and launching the Java 21 Virtual Machine can spike memory consumption during boot. Configure a 4 GB swap file:

BASH

# Check existing swap
sudo swapon --show

# Create 4 GB swapfile
sudo fallocate -l 4G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile

# Make swap persistent across reboots
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab

# Tune swappiness for optimal memory caching
sudo sysctl vm.swappiness=10
echo 'vm.swappiness=10' | sudo tee -a /etc/sysctl.conf

Firewall Hardening with UFW:

Block all unauthorized inbound traffic, leaving only SSH, HTTP, and HTTPS:

BASH

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp      # SSH (adjust if using custom port)
sudo ufw allow 80/tcp      # HTTP
sudo ufw allow 443/tcp     # HTTPS
sudo ufw enable

3. Subdomain DNS Configuration

Before generating SSL certificates, map your subdomain to your VPS IP address at your DNS provider (Cloudflare, Namecheap, Route 53, etc.):

Record TypeHostname / SubdomainTarget / ValueTTLNote
Aspring-elearning<YOUR_VPS_PUBLIC_IP>Auto / 300sIf using Cloudflare, set to DNS Only (Grey Cloud) during initial Certbot validation.

Verify DNS propagation:

BASH

dig +short spring-elearning.mfarim.com
# Must return your VPS public IP address

4. Installing Docker, Nginx & Certbot

Install Docker Engine & Compose via Official Docker Keyring:

BASH

# Remove outdated packages
sudo apt remove -y docker docker-engine docker.io containerd runc

# Add official Docker GPG key
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

# Add official Docker repository
echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

# Install Docker CE and Compose plugin
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

# Enable and start Docker service
sudo systemctl enable docker
sudo systemctl start docker

# Verify installation
docker compose version

Install Host Nginx & Certbot:

BASH

sudo apt install -y nginx certbot python3-certbot-nginx
sudo systemctl enable nginx
sudo systemctl start nginx

5. Repository Setup & Production Environment (.env)

Clone the repository to standard server storage, such as /var/www/spring-elearning:

BASH

sudo mkdir -p /var/www/spring-elearning
sudo chown -R $USER:$USER /var/www/spring-elearning

git clone https://github.com/mfarim/spring-elearning-react.git /var/www/spring-elearning
cd /var/www/spring-elearning

Configure Production Secrets in .env:

Create the production environment file:

BASH

cp .env.example .env
nano .env

Populate .env with strong production values:

INI

# ==============================================================================
# Production Environment Variables
# ==============================================================================

# --- Database Credentials ---
POSTGRES_DB=elearning
POSTGRES_USER=elearning_prod_user
# Generate strong 32-char password via: openssl rand -hex 16
POSTGRES_PASSWORD=Secur3_P0stgr3s_Pr0d_K3y_2026!

# --- JWT Signing Key (Must be valid 256-bit Base64 string!) ---
# Generate via: openssl rand -base64 32
JWT_SECRET=WjgrSmZkN1Fsa1ZReTh4V2tQd3JzZXQ4N2FscTNyd2Vxdzh5aGphaXN1Zz0=

# --- Container Storage Paths ---
STORAGE_UPLOAD_DIR=/app/storage/uploads

# --- Internal Port Forwarding to Host Nginx ---
FRONTEND_PORT=8088
BACKEND_PORT=8080

[!IMPORTANT] Generating a Cryptographically Secure JWT Secret: Execute openssl rand -base64 32 in your terminal and copy the resulting string directly into JWT_SECRET= in .env.


6. Running Production Docker Compose

We use docker-compose.prod.yml, which binds the frontend to loopback 127.0.0.1:8088 and isolates the database:

BASH

docker compose -f docker-compose.prod.yml up -d --build

Verify Container Health:

BASH

docker compose -f docker-compose.prod.yml ps

All 4 containers must report healthy:

  • elearning-postgres (healthy)
  • elearning-redis (healthy)
  • elearning-backend (healthy)
  • elearning-frontend (up, bound to 127.0.0.1:8088->80/tcp)

Verify Startup Logs:

BASH

docker compose -f docker-compose.prod.yml logs -f backend

Look for:

TEXT

Tomcat started on port 8080 (http) with context path '/'
Started BackendApplication in 8.412 seconds

Verify Internal Spring Boot Actuator Health:

BASH

curl -s http://127.0.0.1:8088/actuator/health
# Expected output: {"status":"UP"}

7. Host Nginx Virtual Host Configuration

Now we configure the Host Nginx reverse proxy to route traffic from spring-elearning.mfarim.com to 127.0.0.1:8088, with full support for 50MB file uploads and persistent WebSockets.

Create Virtual Host File:

BASH

sudo nano /etc/nginx/sites-available/spring-elearning.mfarim.com

Insert the following configuration:

NGINX

server {
    server_name spring-elearning.mfarim.com;

    # Allow up to 50MB for learning materials & syllabus PDFs
    client_max_body_size 50M;

    access_log /var/log/nginx/spring-elearning_access.log;
    error_log /var/log/nginx/spring-elearning_error.log;

    # Frontend SPA & REST API
    location / {
        proxy_pass http://127.0.0.1:8088;
        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;
    }

    # Real-Time WebSocket STOMP for CBT Proctoring Telemetry
    location /ws {
        proxy_pass http://127.0.0.1:8088/ws;
        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_read_timeout 86400s;
        proxy_send_timeout 86400s;
    }

    listen 80;
}

Enable Site and Verify Syntax:

BASH

sudo ln -s /etc/nginx/sites-available/spring-elearning.mfarim.com /etc/nginx/sites-enabled/
sudo nginx -t

# If output says "syntax is ok", reload Nginx:
sudo systemctl reload nginx

8. Installing Let's Encrypt SSL (HTTPS) with Certbot

Issue a free, automated TLS/SSL certificate with automatic HTTP-to-HTTPS redirect:

BASH

sudo certbot --nginx -d spring-elearning.mfarim.com

Certbot prompts for:

  1. Email address: For urgent renewal notifications.
  2. Terms of Service: Accept with Y.

Certbot automatically solves the ACME challenge, provisions the certificate, and configures HTTPS redirection in your Nginx config.

Test Automated SSL Renewal:

BASH

sudo certbot renew --dry-run

Output: Congratulations, all simulated renewals succeeded.


9. Production Feature Verification Checklist

Open your browser and navigate to: 👉 https://spring-elearning.mfarim.com

Execute this 5-point verification checklist:

Verification ItemActionExpected Result
1. SSL LockInspect browser address barValid TLS 1.3 certificate issued by Let's Encrypt
2. Admin LoginLogin with [email protected] / passwordEnters Admin Dashboard with statistics
3. Refresh TokenInspect Network tab during active sessionBackground /api/v1/auth/refresh keeps session alive
4. File UploadUpload 15MB PDF module in Teacher PanelFile uploads successfully without HTTP 413 error
5. Live WebSocketOpen CBT Exam Monitor (/exams/:id/monitor)Browser console confirms STOMP connected

10. Automated Redeployment Script (deploy.sh)

To make future updates painless, create an automated deployment script in /var/www/spring-elearning/deploy.sh:

BASH

cat << 'EOF' > /var/www/spring-elearning/deploy.sh
#!/bin/bash
set -e

echo "🚀 [1/4] Pulling latest commits from GitHub..."
cd /var/www/spring-elearning
git pull origin main

echo "📦 [2/4] Building production images and restarting containers..."
docker compose -f docker-compose.prod.yml build
docker compose -f docker-compose.prod.yml up -d --remove-orphans

echo "🧹 [3/4] Pruning dangling images..."
docker image prune -f

echo "✅ [4/4] Verifying container health..."
sleep 10
docker compose -f docker-compose.prod.yml ps

echo "🎉 Deployment successfully completed!"
EOF

chmod +x /var/www/spring-elearning/deploy.sh

Whenever you push new features or bug fixes, simply run:

BASH

./deploy.sh

11. Automated Database Backups & Maintenance

Protect student grades, attendance, and exam results with an automated daily compressed backup.

Create the Backup Script:

BASH

sudo mkdir -p /var/backups/elearning
cat << 'EOF' > /var/backups/elearning/backup-db.sh
#!/bin/bash
BACKUP_DIR="/var/backups/elearning"
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
FILENAME="$BACKUP_DIR/elearning_db_$TIMESTAMP.sql.gz"

# Dump PostgreSQL database directly from Docker container and compress
docker exec elearning-postgres pg_dump -U postgres elearning | gzip > "$FILENAME"

# Prune backups older than 14 days
find "$BACKUP_DIR" -type f -name "*.sql.gz" -mtime +14 -delete

echo "Database backup completed: $FILENAME"
EOF

chmod +x /var/backups/elearning/backup-db.sh

Schedule Cronjob (Runs Daily at 02:00 AM):

BASH

sudo crontab -e

Append:

CRON

0 2 * * * /var/backups/elearning/backup-db.sh >> /var/log/elearning_backup.log 2>&1

12. Troubleshooting Common Production Pitfalls

1. Bind for 0.0.0.0:80 failed: port is already allocated

  • Cause: Host Nginx is already bound to port 80.
  • Remedy: docker-compose.prod.yml binds the frontend container to 127.0.0.1:8088:80, not port 80. Ensure you run compose with -f docker-compose.prod.yml.

2. 413 Request Entity Too Large When Uploading Materials

  • Cause: Default Nginx max body size is 1MB.
  • Remedy: Ensure client_max_body_size 50M; is configured in both Host Nginx (/etc/nginx/sites-available/...) and Container Nginx (frontend/nginx.conf), then reload: sudo systemctl reload nginx.

3. Container Backend Exited with Code 137 (OOM Killed)

  • Cause: Linux killed the JVM due to insufficient system RAM.
  • Remedy:
    1. Ensure Swap Memory is active (swapon --show).
    2. Constrain JVM heap size in backend/Dockerfile using -Xms256m -Xmx512m in the ENTRYPOINT.

4. WebSocket Disconnect / Failed Handshake on CBT Proctoring

  • Cause: Host Nginx dropped the Upgrade headers.
  • Remedy: Verify that the location /ws block includes proxy_set_header Upgrade $http_upgrade; and proxy_read_timeout 86400s;.

5. Loading Demo Database Seed Data

To populate realistic student candidates and question banks on a fresh server:

BASH

docker exec -i elearning-postgres psql -U postgres -d elearning < /var/www/spring-elearning/backend/src/main/resources/db/migration/seed_demo_data.sql

Conclusion & Series Next Steps

Deploying enterprise applications does not require expensive managed cloud infrastructure. By combining Docker Compose container isolation, Host Nginx reverse proxying with Certbot SSL, Swap memory allocation, and automated backups, our Java Spring Boot 3 & React 19 E-Learning platform runs smoothly, securely, and cost-effectively on a single VPS.

In Part 4, we will explore Domain Model & PostgreSQL Flyway Schema Design, analyzing the schema evolution and database relationships underpinning this platform.

Continue Reading

Previous article

← Previous Article

Java Spring Boot + React E-Learning: Production Readiness, Security Hardening & Zero-Downtime Resilience (PR #1 Breakdown)

Next Article →

Rebuilding Laravel E-Learning to Python FastAPI & React: High-Concurrency Async Architecture & Cloud-Ready Migration

Next article