Skip to main content

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

  1. Oracle Cloud Free Tier Setup
  2. Network Configuration
  3. Server Setup
  4. Nginx Configuration
  5. PM2 Configuration
  6. MySQL Configuration
  7. SSL/TLS Setup
  8. SELinux Configuration
  9. DNS Configuration
  10. Security Hardening
  11. Server Directory Structure

1. Oracle Cloud Free Tier Setup

Account Creation

  1. Navigate to https://cloud.oracle.com/free
  2. Sign up for an Oracle Cloud Free Tier account
  3. Provide billing information (Free Tier includes Always Free resources)
  4. Complete email verification and identity verification

VM Provisioning

SettingValue
ShapeVM.Standard.E4.Flex
OCPU2
RAM4 GB
OSOracle Linux 9 (x86)
Boot Volume50 GB
Public IP170.9.14.60

Steps:

  1. Go to Compute → Instances → Create Instance
  2. Select Image: Oracle Linux 9 (latest)
  3. Select Shape: VM.Standard.E4.Flex → 2 OCPU, 4 GB RAM
  4. Under Networking: Select the VCN and public subnet (created below)
  5. Under Add SSH Keys: Upload the public key matching /Users/fuhsi/Documents/helpdesk/lofty-golden-oil-erp/ssh-key-2026-07-25.key
  6. Click Create and wait for instance to reach "Running" state
  7. 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

  1. Go to Networking → Virtual Cloud Networks → Start VCN Wizard
  2. Select "Create VCN with Internet Connectivity"
  3. 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

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

SettingValue
Namelgo-public-subnet
CIDR10.0.0.0/24
Route TablePublic Route Table (→ Internet Gateway)
Subnet AccessPublic

Security Lists

⚠️ CRITICAL: Source and Destination ports must NOT be swapped.

Default Security List for lgo-er

DirectionSourceDestinationProtocolPortDescription
Ingress0.0.0.0/0TCP22SSH access
Ingress0.0.0.0/0TCP80HTTP (redirect to HTTPS)
Ingress0.0.0.0/0TCP443HTTPS (production)
Ingress0.0.0.0/0TCP8443Staging (direct access)
Egress0.0.0.0/0AllAllAllow 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

Settinglgo-prodlgo-staging
Namelgo-prodlgo-staging
Port40008443
Databaselgo_erp_prodlgo_erp_staging
NODE_ENVproductionstaging
Modeforkfork
AccessVia Nginx (443)Direct (8443)

Key PM2 Settings Explained

SettingValuePurpose
exec_modeforkSingle process (not cluster)
autorestarttrueAuto-restart on crash
watchfalseDon't watch files for changes
max_memory_restart256MRestart if memory exceeds 256MB
exp_backoff_restart_delay100Exponential backoff between restarts
restart_delay3000Wait 3s between restarts
max_restarts15Stop restarting after 15 attempts
min_uptime5sProcess 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

DatabasePurposePM2 Process
lgo_erp_prodProduction datalgo-prod
lgo_erp_stagingStaging/testing datalgo-staging

User Accounts

UserHostPrivileges
lgo127.0.0.1Full access to lgo_erp_prod and lgo_erp_staging
rootlocalhostFull 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,
});
SettingValuePurpose
connectionLimit5Max simultaneous connections
waitForConnectionstrueQueue requests when limit reached
queueLimit0No limit on queued requests
dateStringstrueReturn 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:

  1. Obtains a certificate from Let's Encrypt
  2. Modifies the Nginx config to include SSL directives
  3. 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

TypeNameValueTTL
A@170.9.14.60300
Awww170.9.14.60300

Steps

  1. Log in to your DNS provider (where loftygoldenoil.com is registered)
  2. Navigate to DNS Management
  3. Add/verify A record: @170.9.14.60
  4. Add/verify A record: www170.9.14.60
  5. 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:

  1. Go to Networking → Virtual Cloud Networks → lgo-vcn
  2. Click on Public Subnet → Default Security List
  3. 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)

Password Policies

PolicyRequirement
Minimum length6 characters
Hashingbcrypt (10 rounds)
JWT expiry24 hours
StorageNever in plain text

Additional Security Measures

  1. Fail2ban — Install to block brute-force SSH attempts:

    sudo dnf install -y fail2ban
    sudo systemctl enable --now fail2ban
  2. Unattended Updates — Enable automatic security patches:

    sudo dnf install -y dnf-automatic
    sudo systemctl enable --now dnf-automatic-install.timer
  3. Audit Logging — The ERP includes an audit_log table 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