Infrastructure Guide — Lofty Golden Oil ERP
Version: 2.0
Last Updated: July 2026
Platform: Oracle Cloud Free Tier
Server IP: 170.9.14.60
Table of Contents
- Oracle Cloud Free Tier Setup
- Network Configuration
- Server Setup
- Nginx Configuration
- PM2 Configuration
- MySQL Configuration
- SSL/TLS Setup
- SELinux Configuration
- DNS Configuration
- Security Hardening
- Server Directory Structure
1. Oracle Cloud Free Tier Setup
Account Creation
- Navigate to https://cloud.oracle.com/free
- Sign up for an Oracle Cloud Free Tier account
- Provide billing information (Free Tier includes Always Free resources)
- Complete email verification and identity verification
VM Provisioning
| Setting | Value |
|---|---|
| Shape | VM.Standard.E4.Flex |
| OCPU | 2 |
| RAM | 4 GB |
| OS | Oracle Linux 9 (x86) |
| Boot Volume | 50 GB |
| Public IP | 170.9.14.60 |
Steps:
- Go to Compute → Instances → Create Instance
- Select Image: Oracle Linux 9 (latest)
- Select Shape: VM.Standard.E4.Flex → 2 OCPU, 4 GB RAM
- Under Networking: Select the VCN and public subnet (created below)
- Under Add SSH Keys: Upload the public key matching
/Users/fuhsi/Documents/helpdesk/lofty-golden-oil-erp/ssh-key-2026-07-25.key - Click Create and wait for instance to reach "Running" state
- Note the Public IP: 170.9.14.60
SSH Key Pair
The SSH key pair used for deployment:
- Private key:
/Users/fuhsi/Documents/helpdesk/lofty-golden-oil-erp/ssh-key-2026-07-25.key - Deploy key (copy-paste):
deploy/vps_ssh_key.txt - GitHub Actions deploy key:
deploy/oracle_key
Ensure the private key has strict permissions:
chmod 600 /path/to/ssh-key-2026-07-25.key
2. Network Configuration
VCN (Virtual Cloud Network) Setup
- Go to Networking → Virtual Cloud Networks → Start VCN Wizard
- Select "Create VCN with Internet Connectivity"
- Configure:
- Name:
lgo-vcn - Compartment: Root
- VCN CIDR:
10.0.0.0/16 - Public Subnet CIDR:
10.0.0.0/24 - Private Subnet CIDR:
10.0.1.0/24
- Name:
Internet Gateway
Created automatically with the VCN wizard. Ensure:
- Name:
lgo-igw - Status: Attached to
lgo-vcn - Route Table: Attached to the public subnet's route table with destination
0.0.0.0/0
Public Subnet
| Setting | Value |
|---|---|
| Name | lgo-public-subnet |
| CIDR | 10.0.0.0/24 |
| Route Table | Public Route Table (→ Internet Gateway) |
| Subnet Access | Public |
Security Lists
⚠️ CRITICAL: Source and Destination ports must NOT be swapped.
Default Security List for lgo-er
| Direction | Source | Destination | Protocol | Port | Description |
|---|---|---|---|---|---|
| Ingress | 0.0.0.0/0 | — | TCP | 22 | SSH access |
| Ingress | 0.0.0.0/0 | — | TCP | 80 | HTTP (redirect to HTTPS) |
| Ingress | 0.0.0.0/0 | — | TCP | 443 | HTTPS (production) |
| Ingress | 0.0.0.0/0 | — | TCP | 8443 | Staging (direct access) |
| Egress | — | 0.0.0.0/0 | All | All | Allow all outbound |
Important Notes:
- Source Port should be All (not a specific port)
- Destination Port should be the specific ports (22, 80, 443, 8443)
- Do not create separate rules for each port direction unless explicitly needed
3. Server Setup
Initial SSH Connection
ssh -i /path/to/ssh-key-2026-07-25.key opc@170.9.14.60
System Updates
sudo dnf update -y
sudo dnf upgrade -y
Node.js 22 Installation
# Install Node.js 22 via NodeSource
curl -fsSL https://rpm.nodesource.com/setup_22.x | sudo bash -
sudo dnf install -y nodejs
# Verify
node --version # Should show v22.x.x
npm --version # Should show 10.x.x
MySQL 8.0 Installation
# Install MySQL 8.0
sudo dnf install -y mysql-server
# Start and enable MySQL
sudo systemctl start mysqld
sudo systemctl enable mysqld
# Secure installation
sudo mysql_secure_installation
MySQL User and Database Setup
-- Connect as root
sudo mysql
-- Create databases
CREATE DATABASE IF NOT EXISTS lgo_erp_prod CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE IF NOT EXISTS lgo_erp_staging CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- Create user
CREATE USER 'lgo'@'127.0.0.1' IDENTIFIED BY 'Lg0Erp2026!Db';
GRANT ALL PRIVILEGES ON lgo_erp_prod.* TO 'lgo'@'127.0.0.1';
GRANT ALL PRIVILEGES ON lgo_erp_staging.* TO 'lgo'@'127.0.0.1';
FLUSH PRIVILEGES;
-- Apply schema to production
USE lgo_erp_prod;
SOURCE /opt/lgo-erp/server/schema.sql;
SOURCE /opt/lgo-erp/server/seed.sql;
-- Apply schema to staging
USE lgo_erp_staging;
SOURCE /opt/lgo-erp/server/schema.sql;
SOURCE /opt/lgo-erp/server/seed.sql;
Gotcha: The schema.sql file contains USE lgo_erp; — this line must be stripped or replaced before applying to a specific database. Alternatively, run the SQL statements directly against the target database without using the USE statement.
Nginx Installation
sudo dnf install -y nginx
sudo systemctl start nginx
sudo systemctl enable nginx
PM2 Installation
sudo npm install -g pm2@7.0.3
# Verify
pm2 --version
Certbot Installation
sudo dnf install -y certbot python3-certbot-nginx
Application Directory Setup
# Create application directory
sudo mkdir -p /opt/lgo-erp/server/logs
sudo chown -R opc:opc /opt/lgo-erp
# Clone the repository
cd /opt/lgo-erp
git clone https://github.com/abono200/lofty-golden-oil-erp.git .
# Install server dependencies
cd server && npm install --production && cd ..
# Build frontend
npm ci && npm run build
4. Nginx Configuration
Configuration File
Location: /etc/nginx/conf.d/lgo-erp.conf
Full Configuration
# /etc/nginx/conf.d/lgo-erp.conf
# Lofty Golden Oil ERP — Production Nginx Configuration
# Rate limiting
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
# Upstream — Node.js Express server
upstream lgo_backend {
server 127.0.0.1:4000;
keepalive 32;
}
# HTTP → HTTPS redirect
server {
listen 80;
listen [::]:80;
server_name loftygoldenoil.com www.loftygoldenoil.com;
# Let's Encrypt challenge
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
return 301 https://$host$request_uri;
}
}
# Production — HTTPS
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name loftygoldenoil.com www.loftygoldenoil.com;
# SSL Certificate — Let's Encrypt
ssl_certificate /etc/letsencrypt/live/loftygoldenoil.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/loftygoldenoil.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# SSL Settings
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
# 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 Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# Root — React SPA
root /opt/lgo-erp/dist;
index index.html;
# API Proxy → Node.js Express
location /api/ {
proxy_pass http://lgo_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 "";
# Rate limiting
limit_req zone=api burst=20 nodelay;
# Body size (for backup upload)
client_max_body_size 15m;
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# Static assets — aggressive caching
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|wasm)$ {
expires 30d;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# SPA fallback — all routes serve index.html
location / {
try_files $uri $uri/ /index.html;
}
# Gzip
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml application/wasm;
}
Gotcha: Default Server Block
The default nginx.conf file contains a server block that may conflict. Comment it out:
sudo nano /etc/nginx/nginx.conf
# Comment out the default `server { ... }` block (lines ~37-57)
Apply Configuration
sudo nginx -t # Test configuration
sudo systemctl reload nginx # Apply without downtime
5. PM2 Configuration
Configuration File
Location: /opt/lgo-erp/ecosystem.config.cjs (also at server/ecosystem.config.cjs in the repo)
Full Configuration
module.exports = {
apps: [{
name: 'lgo-erp',
script: 'server.js',
cwd: '/opt/lgo-erp/server',
exec_mode: 'fork',
env: {
NODE_ENV: 'production',
PORT: 8443,
DB_HOST: '127.0.0.1',
DB_USER: 'lgo',
DB_PASS: 'Lg0Erp2026!Db',
DB_NAME: 'lgo_erp',
JWT_SECRET: 'lgo-prod-jwt-secret-2026-change-me',
LGO_STATIC_DIR: '/opt/lgo-erp/dist'
},
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: '256M',
exp_backoff_restart_delay: 100,
restart_delay: 3000,
max_restarts: 15,
min_uptime: '5s',
log_date_format: 'YYYY-MM-DD HH:mm:ss',
error_file: '/opt/lgo-erp/server/logs/error.log',
out_file: '/opt/lgo-erp/server/logs/out.log',
merge_logs: true
}]
};
Production vs Staging PM2 Processes
| Setting | lgo-prod | lgo-staging |
|---|---|---|
| Name | lgo-prod | lgo-staging |
| Port | 4000 | 8443 |
| Database | lgo_erp_prod | lgo_erp_staging |
| NODE_ENV | production | staging |
| Mode | fork | fork |
| Access | Via Nginx (443) | Direct (8443) |
Key PM2 Settings Explained
| Setting | Value | Purpose |
|---|---|---|
exec_mode | fork | Single process (not cluster) |
autorestart | true | Auto-restart on crash |
watch | false | Don't watch files for changes |
max_memory_restart | 256M | Restart if memory exceeds 256MB |
exp_backoff_restart_delay | 100 | Exponential backoff between restarts |
restart_delay | 3000 | Wait 3s between restarts |
max_restarts | 15 | Stop restarting after 15 attempts |
min_uptime | 5s | Process must run 5s to be considered "started" |
Starting with Ecosystem Config
cd /opt/lgo-erp
pm2 start ecosystem.config.cjs
pm2 save
6. MySQL Configuration
Database Architecture
| Database | Purpose | PM2 Process |
|---|---|---|
lgo_erp_prod | Production data | lgo-prod |
lgo_erp_staging | Staging/testing data | lgo-staging |
User Accounts
| User | Host | Privileges |
|---|---|---|
lgo | 127.0.0.1 | Full access to lgo_erp_prod and lgo_erp_staging |
root | localhost | Full access (admin only) |
Connection Pooling
The Express server uses mysql2/promise with connection pooling:
const pool = mysql.createPool({
host: '127.0.0.1',
user: 'lgo',
password: 'Lg0Erp2026!Db',
database: 'lgo_erp_prod',
waitForConnections: true,
connectionLimit: 5,
queueLimit: 0,
dateStrings: true,
});
| Setting | Value | Purpose |
|---|---|---|
connectionLimit | 5 | Max simultaneous connections |
waitForConnections | true | Queue requests when limit reached |
queueLimit | 0 | No limit on queued requests |
dateStrings | true | Return dates as strings |
Character Set
All databases use:
- Character Set:
utf8mb4 - Collation:
utf8mb4_unicode_ci
MySQL Performance Tuning
Key settings in /etc/my.cnf or /etc/my.cnf.d/:
[mysqld]
# Buffer pool — use ~50% of available RAM
innodb_buffer_pool_size = 2G
# Connections
max_connections = 50
# Character set
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
# Logging
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 2
# Binary logging (for backup/recovery)
log_bin = mysql-bin
expire_logs_days = 7
Useful MySQL Commands
# Connect to production
mysql -u lgo -p lgo_erp_prod
# Show databases
mysql -u lgo -p -e "SHOW DATABASES;"
# Show tables
mysql -u lgo -p lgo_erp_prod -e "SHOW TABLES;"
# Check table status
mysql -u lgo -p lgo_erp_prod -e "SHOW TABLE STATUS;"
# Show process list
mysql -u lgo -p -e "SHOW PROCESSLIST;"
# Check user privileges
mysql -u root -e "SHOW GRANTS FOR 'lgo'@'127.0.0.1';"
7. SSL/TLS Setup
Initial Certificate Issuance
sudo certbot --nginx -d loftygoldenoil.com -d www.loftygoldenoil.com
This automatically:
- Obtains a certificate from Let's Encrypt
- Modifies the Nginx config to include SSL directives
- Sets up HTTP → HTTPS redirect
Certificate Files
/etc/letsencrypt/live/loftygoldenoil.com/
├── fullchain.pem # Full certificate chain (what Nginx uses)
├── privkey.pem # Private key
├── cert.pem # Domain certificate only
└── chain.pem # Intermediate CA certificates
Auto-Renewal
Certbot installs a systemd timer:
# Check timer status
sudo systemctl status certbot-renew.timer
# List certbot timers
sudo systemctl list-timers | grep certbot
The timer runs twice daily and renews certificates expiring within 30 days.
Manual Renewal
# Test renewal process
sudo certbot renew --dry-run
# Force renewal
sudo certbot renew
sudo systemctl reload nginx
Renewal Hook
Create a post-reload script at /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh:
#!/bin/bash
systemctl reload nginx
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
View Certificate Expiry
sudo certbot certificates
8. SELinux Configuration
Oracle Linux 9 has SELinux enabled by default in enforcing mode.
Required Booleans
# Allow Nginx to make outbound network connections (for API proxy)
sudo setsebool -P httpd_can_network_connect on
# Allow Nginx to connect to MySQL
sudo setsebool -P httpd_can_network_connect_db on
Verify SELinux Status
# Check current mode
getenforce
# Check boolean values
getsebool -a | grep httpd_can_network
If SELinux Causes Issues
# View SELinux denials
sudo ausearch -m avc -ts recent
# Generate policy module from denials
sudo audit2allow -a -M lgo-nginx
# Install the module
sudo semodule -i lgo-nginx.pp
# Permissive mode (for debugging only — never leave in production)
sudo setenforce 0
9. DNS Configuration
A Record Setup
| Type | Name | Value | TTL |
|---|---|---|---|
| A | @ | 170.9.14.60 | 300 |
| A | www | 170.9.14.60 | 300 |
Steps
- Log in to your DNS provider (where loftygoldenoil.com is registered)
- Navigate to DNS Management
- Add/verify A record:
@→170.9.14.60 - Add/verify A record:
www→170.9.14.60 - Set TTL to 300 seconds (5 minutes) for quick propagation
Verify DNS
# Check A record
dig loftygoldenoil.com +short
# Expected: 170.9.14.60
# Check www subdomain
dig www.loftygoldenoil.com +short
# Expected: 170.9.14.60
# Full DNS lookup
dig loftygoldenoil.com
# Test HTTP redirect
curl -I http://loftygoldenoil.com
# Should return 301 → https://loftygoldenoil.com
DNS Propagation
After changing DNS records, propagation can take:
- Minimum: 5 minutes (with low TTL)
- Typical: 15-30 minutes
- Maximum: 48 hours (rare)
Use https://dnschecker.org to verify global propagation.
10. Security Hardening
SSH Key-Only Authentication
Disable password authentication:
sudo nano /etc/ssh/sshd_config
Set the following:
PasswordAuthentication no
PubkeyAuthentication yes
PermitRootLogin prohibit-password
ChallengeResponseAuthentication no
Restart SSH:
sudo systemctl restart sshd
Firewall Rules (firewalld)
# Check firewall status
sudo firewall-cmd --state
# Open required ports
sudo firewall-cmd --permanent --add-port=80/tcp
sudo firewall-cmd --permanent --add-port=443/tcp
sudo firewall-cmd --permanent --add-port=8443/tcp
sudo firewall-cmd --permanent --add-port=22/tcp
# Reload firewall
sudo firewall-cmd --reload
# Verify
sudo firewall-cmd --list-all
Oracle Cloud Security Lists
Verify in Oracle Cloud Console:
- Go to Networking → Virtual Cloud Networks → lgo-vcn
- Click on Public Subnet → Default Security List
- Verify ingress rules:
- Source:
0.0.0.0/0, Destination Port:22(SSH) - Source:
0.0.0.0/0, Destination Port:80(HTTP) - Source:
0.0.0.0/0, Destination Port:443(HTTPS) - Source:
0.0.0.0/0, Destination Port:8443(Staging)
- Source:
Password Policies
| Policy | Requirement |
|---|---|
| Minimum length | 6 characters |
| Hashing | bcrypt (10 rounds) |
| JWT expiry | 24 hours |
| Storage | Never in plain text |
Additional Security Measures
-
Fail2ban — Install to block brute-force SSH attempts:
sudo dnf install -y fail2bansudo systemctl enable --now fail2ban -
Unattended Updates — Enable automatic security patches:
sudo dnf install -y dnf-automaticsudo systemctl enable --now dnf-automatic-install.timer -
Audit Logging — The ERP includes an
audit_logtable tracking all user actions
11. Server Directory Structure
Complete File Tree
/opt/lgo-erp/ # Application root
├── dist/ # React SPA build output (served by Nginx)
│ ├── index.html # Entry point
│ ├── assets/ # JS, CSS bundles
│ │ ├── index-[hash].js
│ │ ├── index-[hash].css
│ │ └── ...
│ ├── favicon.ico
│ └── *.svg, *.wasm # Static assets
├── server/ # Express.js backend
│ ├── server.js # Main API server
│ ├── email.js # Resend email integration
│ ├── package.json # Server dependencies
│ ├── package-lock.json
│ ├── node_modules/ # Server npm packages
│ ├── schema.sql # Database schema
│ ├── seed.sql # Seed data
│ ├── ecosystem.config.cjs # PM2 configuration
│ └── logs/ # Application logs
│ ├── error.log # Error output
│ └── out.log # Standard output
├── package.json # Root package.json (frontend)
├── package-lock.json
├── vite.config.js # Vite build configuration
├── index.html # React entry HTML
├── src/ # React source code
│ ├── App.jsx
│ ├── main.jsx
│ ├── views/ # Page components
│ │ ├── Dashboard.jsx
│ │ ├── Inventory.jsx
│ │ ├── Production.jsx
│ │ ├── Sales.jsx
│ │ ├── Accounting.jsx
│ │ ├── Customers.jsx
│ │ ├── Suppliers.jsx
│ │ ├── Users.jsx
│ │ ├── ActivityLog.jsx
│ │ ├── Login.jsx
│ │ ├── Statements.jsx
│ │ └── accounting/
│ │ ├── ComplianceDesk.jsx
│ │ ├── DrilldownModal.jsx
│ │ ├── LedgerBook.jsx
│ │ └── ReportsMenu.jsx
│ ├── components/ # Reusable UI components
│ │ ├── AlertBadge.jsx
│ │ ├── BulkActions.jsx
│ │ ├── DataTable.jsx
│ │ ├── Pagination.jsx
│ │ ├── SearchBar.jsx
│ │ ├── ShortcutHelp.jsx
│ │ └── UndoButton.jsx
│ └── services/ # API client functions
├── .github/workflows/deploy.yml # CI/CD pipeline
├── deploy/ # Deployment artifacts
│ ├── oracle_key # Private SSH key for VPS
│ ├── vps_ssh_key.txt # Copy-paste version of SSH key
│ ├── nginx.conf # Reference Nginx config
│ ├── ecosystem.config.cjs # Reference PM2 config
│ ├── setup.sh # Server setup script
│ └── lgo-erp.service # Systemd service file
├── docs/ # Documentation
│ ├── 00-overview/
│ ├── 01-user-guides/
│ ├── 02-admin-guides/
│ ├── 03-sops/
│ ├── 04-training/
│ ├── 05-technical/
│ ├── 06-security/
│ ├── 07-compliance/
│ ├── 08-devops/
│ ├── 09-reference/
│ └── 10-appendix/
└── backup-api/ # Backup API server
/etc/nginx/conf.d/lgo-erp.conf # Nginx virtual host config
/etc/letsencrypt/live/loftygoldenoil.com/ # SSL certificates
├── fullchain.pem
├── privkey.pem
├── cert.pem
└── chain.pem
/var/log/nginx/access.log # Nginx access logs
/var/log/nginx/error.log # Nginx error logs
/var/log/mysql/slow.log # MySQL slow query log
Process Architecture
┌──────────────┐
│ Internet │
└──────┬───────┘
│
┌──────▼───────┐
│ Nginx │
│ (443) │
└──────┬───────┘
│
┌────────────┼────────────┐
│ │
┌────────▼────────┐ ┌─────────▼─────────┐
│ Static Files │ │ /api/ Proxy │
│ /opt/lgo-erp/ │ │ → 127.0.0.1:4000 │
│ dist/* │ └─────────┬─────────┘
└─────────────────┘ │
┌──────▼───────┐
│ PM2 │
│ lgo-prod │
│ (Node.js) │
└──────┬───────┘
│
┌──────▼───────┐
│ MySQL 8.0 │
│ lgo_erp_prod │
└──────────────┘
Staging (direct access):
http://170.9.14.60:8443 → PM2 lgo-staging → MySQL lgo_erp_staging