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
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):
Key Architectural Advantages:
- 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. - Database Isolation: PostgreSQL and Redis containers do not expose ports to the public internet (
0.0.0.0), preventing brute-force database attacks. - 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 Type | Hostname / Subdomain | Target / Value | TTL | Note |
|---|---|---|---|---|
| A | spring-elearning | <YOUR_VPS_PUBLIC_IP> | Auto / 300s | If 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 32in your terminal and copy the resulting string directly intoJWT_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 to127.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:
- Email address: For urgent renewal notifications.
- 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 Item | Action | Expected Result |
|---|---|---|
| 1. SSL Lock | Inspect browser address bar | Valid TLS 1.3 certificate issued by Let's Encrypt |
| 2. Admin Login | Login with [email protected] / password | Enters Admin Dashboard with statistics |
| 3. Refresh Token | Inspect Network tab during active session | Background /api/v1/auth/refresh keeps session alive |
| 4. File Upload | Upload 15MB PDF module in Teacher Panel | File uploads successfully without HTTP 413 error |
| 5. Live WebSocket | Open 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.ymlbinds the frontend container to127.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:
- Ensure Swap Memory is active (
swapon --show). - Constrain JVM heap size in
backend/Dockerfileusing-Xms256m -Xmx512min theENTRYPOINT.
- Ensure Swap Memory is active (
4. WebSocket Disconnect / Failed Handshake on CBT Proctoring
- Cause: Host Nginx dropped the
Upgradeheaders. - Remedy: Verify that the
location /wsblock includesproxy_set_header Upgrade $http_upgrade;andproxy_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.
- Production Live URL: spring-elearning.mfarim.com
- Open Source Repository: github.com/mfarim/spring-elearning-react

