Skip to main content

Configuration Reference

Lofty Golden Oil ERP — All Configuration Options and Settings


Table of Contents

  1. Environment Variables
  2. Nginx Configuration
  3. PM2 Ecosystem Configuration
  4. MySQL Configuration
  5. SSL/TLS Configuration
  6. Vite Configuration
  7. Tailwind Configuration
  8. Application Settings
  9. API Configuration
  10. Logging

1. Environment Variables

All environment variables are defined in server/.env (not committed to version control).

VariableRequiredDefaultDescription
NODE_ENVYesdevelopmentRuntime environment: development, staging, or production
PORTYes4000Express server listening port
DB_HOSTYeslocalhostMySQL server hostname or IP
DB_PORTYes3306MySQL server port
DB_USERYeslgoMySQL username
DB_PASSWORDYesMySQL password
DB_NAMEYesMySQL database name (lgo_erp_prod or lgo_erp_staging)
JWT_SECRETYesSecret key for HS256 JWT signing. Must be a long random string.
RESEND_API_KEYYesResend HTTP API key for email delivery
RESEND_FROM_EMAILYesSender email address (must be verified in Resend)
FRONTEND_URLYesFrontend origin for CORS and email links (https://loftygoldenoil.com)

Example .env File

# Production
NODE_ENV=production
PORT=4000
DB_HOST=localhost
DB_PORT=3306
DB_USER=lgo
DB_PASSWORD=secure_password_here
DB_NAME=lgo_erp_prod
JWT_SECRET=your_256_bit_random_secret_here
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxx
RESEND_FROM_EMAIL=noreply@loftygoldenoil.com
FRONTEND_URL=https://loftygoldenoil.com

# Staging
NODE_ENV=staging
PORT=8443
DB_NAME=lgo_erp_staging
FRONTEND_URL=http://170.9.14.60:8443

Security Notes

  • Never commit .env files to version control
  • Use different JWT_SECRET values for staging and production
  • JWT_SECRET should be at least 32 characters, randomly generated
  • Rotate RESEND_API_KEY periodically
  • DB_PASSWORD should be a strong, unique password

2. Nginx Configuration

Nginx 1.20.1 handles SSL termination, static file serving, API proxying, and SPA fallback.

Full Annotated Configuration

# /etc/nginx/conf.d/lgo-erp.conf

# Upstream definition for the Express backend
upstream lgo_backend {
server 127.0.0.1:4000; # Express server (PM2 managed)
keepalive 64; # Keep connections alive for reuse
}

# Redirect HTTP to HTTPS
server {
listen 80;
server_name loftygoldenoil.com www.loftygoldenoil.com;

# Allow Let's Encrypt challenge
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}

# Redirect all other HTTP traffic to HTTPS
location / {
return 301 https://$host$request_uri;
}
}

# Main HTTPS server
server {
listen 443 ssl http2;
server_name loftygoldenoil.com www.loftygoldenoil.com;

# --- SSL Configuration ---
ssl_certificate /etc/letsencrypt/live/loftygoldenoil.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/loftygoldenoil.com/privkey.pem;

# Modern SSL configuration
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;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;

# --- 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;

# --- Gzip Compression ---
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
gzip_min_length 256;
gzip_vary on;

# --- Static Files (React Build) ---
location / {
root /var/www/lgo-erp/client/dist; # Vite build output
try_files $uri $uri/ /index.html; # SPA fallback

# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

# --- API Proxy ---
location /api/ {
proxy_pass http://lgo_backend; # Proxy to Express
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_cache_bypass $http_upgrade;

# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;

# Body size limit (match Express 15MB)
client_max_body_size 15m;
}

# --- Staging Server (separate block on port 8443) ---
# listen 8443 ssl;
# server_name 170.9.14.60;
# ... (same SSL and proxy config, different ports)
}

Key Nginx Directives Explained

DirectivePurpose
try_files $uri $uri/ /index.htmlSPA fallback — serves index.html for client-side routes
proxy_pass http://lgo_backendForwards /api/* requests to the Express upstream
proxy_set_header X-Real-IPPasses client IP to Express for logging
client_max_body_size 15mMatches Express body parser limit
gzip onCompresses responses for faster loading
ssl_session_cache shared:SSL:10mCaches SSL sessions for performance

Reload Nginx

sudo nginx -t # Test configuration syntax
sudo systemctl reload nginx # Apply changes without downtime

3. PM2 Ecosystem Configuration

File: ecosystem.config.cjs

// ecosystem.config.cjs
module.exports = {
apps: [
{
name: 'lgo-prod',
script: 'server/server.js',
instances: 1,
exec_mode: 'fork',
env: {
NODE_ENV: 'production',
PORT: 4000,
},
// Memory and restart settings
max_memory_restart: '300M',
exp_backoff_restart_delay: 100,

// Logging
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
error_file: '/home/opc/logs/lgo-prod-error.log',
out_file: '/home/opc/logs/lgo-prod-out.log',
merge_logs: true,

// Restart behavior
autorestart: true,
watch: false,
max_restarts: 10,
min_uptime: '5s',

// Graceful restart
kill_timeout: 5000,
listen_timeout: 10000,
},
{
name: 'lgo-staging',
script: 'server/server.js',
instances: 1,
exec_mode: 'fork',
env: {
NODE_ENV: 'staging',
PORT: 8443,
},
max_memory_restart: '250M',
exp_backoff_restart_delay: 100,

log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
error_file: '/home/opc/logs/lgo-staging-error.log',
out_file: '/home/opc/logs/lgo-staging-out.log',
merge_logs: true,

autorestart: true,
watch: false,
max_restarts: 10,
min_uptime: '5s',

kill_timeout: 5000,
listen_timeout: 10000,
},
],
};

Configuration Explained

OptionValuePurpose
namelgo-prod / lgo-stagingProcess identifier for PM2 commands
scriptserver/server.jsEntry point for the Express server
instances1Single instance (VPS has 2 OCPU, 4GB RAM)
exec_modeforkSingle process mode (no clustering)
PORT4000 / 8443Passed to Express via process.env.PORT
max_memory_restart300M / 250MRestart if memory exceeds limit
autorestarttrueAuto-restart on crash
max_restarts10Maximum restart attempts before giving up
min_uptime5sProcess must run 5s to be considered "started"
kill_timeout50005s to gracefully shut down before SIGKILL
merge_logstrueCombine stdout/stderr into single log stream

PM2 Commands

pm2 status # View running processes
pm2 logs # Tail all logs
pm2 logs lgo-prod # Tail production logs
pm2 monit # Real-time monitoring dashboard
pm2 restart lgo-prod # Restart production
pm2 restart lgo-staging # Restart staging
pm2 stop lgo-prod # Stop production
pm2 delete lgo-prod # Remove from PM2
pm2 save # Save current process list
pm2 resurrect # Restore saved process list
pm2 startup # Configure PM2 to start on boot

Log Locations

/home/opc/logs/lgo-prod-out.log # Production stdout
/home/opc/logs/lgo-prod-error.log # Production stderr
/home/opc/logs/lgo-staging-out.log # Staging stdout
/home/opc/logs/lgo-staging-error.log# Staging stderr

4. MySQL Configuration

Database Summary

PropertyProductionStaging
Database Namelgo_erp_prodlgo_erp_staging
Hostlocalhostlocalhost
Port33063306
Userlgolgo
Charsetutf8mb4utf8mb4
Connection Pool55

Connection Pool (in server.js)

const pool = mysql.createPool({
host: process.env.DB_HOST,
port: process.env.DB_PORT,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
waitForConnections: true,
connectionLimit: 5, // Maximum simultaneous connections
queueLimit: 0, // No limit on queued requests
charset: 'utf8mb4',
timezone: '+00:00',
});

User Privileges

-- Create the application user
CREATE USER IF NOT EXISTS 'lgo'@'localhost' IDENTIFIED BY 'your_password';

-- Grant full privileges on the application database
GRANT ALL PRIVILEGES ON lgo_erp_prod.* TO 'lgo'@'localhost';
GRANT ALL PRIVILEGES ON lgo_erp_staging.* TO 'lgo'@'localhost';

FLUSH PRIVILEGES;

For Oracle Linux 9 with 4GB RAM:

# /etc/my.cnf or /etc/mysql/mysql.conf.d/mysqld.cnf

[mysqld]
# Basic settings
port = 3306
bind-address = 127.0.0.1
datadir = /var/lib/mysql
socket = /var/lib/mysql/mysql.sock

# Character set
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci

# InnoDB settings (tuned for 4GB RAM)
innodb_buffer_pool_size = 1G # ~25% of RAM
innodb_log_file_size = 256M
innodb_flush_log_at_trx_commit = 1 # ACID compliance
innodb_lock_wait_timeout = 50

# Connection settings
max_connections = 50 # Sufficient for ERP workload
wait_timeout = 28800 # 8 hours
interactive_timeout = 28800

# Query cache (MySQL 8.0 removed query cache, use proxy instead)
# Performance schema
performance_schema = ON

# Slow query log
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 2 # Log queries taking > 2 seconds

# Binary logging (for point-in-time recovery)
log-bin = mysql-bin
binlog_expire_logs_seconds = 604800 # 7 days
max_binlog_size = 100M

Table Schema Overview

-- Users table
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(100) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
full_name VARCHAR(255) NOT NULL,
role ENUM('admin', 'production', 'sales', 'accountant') NOT NULL,
status ENUM('active', 'inactive') DEFAULT 'active',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

-- Inventory table
CREATE TABLE IF NOT EXISTS inventory (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
category VARCHAR(100),
quantity DECIMAL(10,2) DEFAULT 0,
unit VARCHAR(50) DEFAULT 'litres',
price DECIMAL(10,2) DEFAULT 0,
cost DECIMAL(10,2) DEFAULT 0,
low_stock_threshold DECIMAL(10,2) DEFAULT 100,
expiry_date DATE,
batch_number VARCHAR(100),
status ENUM('active', 'inactive') DEFAULT 'active',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

-- Settings table (key-value)
CREATE TABLE IF NOT EXISTS settings (
id INT AUTO_INCREMENT PRIMARY KEY,
setting_key VARCHAR(100) UNIQUE NOT NULL,
setting_value TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

-- Audit log
CREATE TABLE IF NOT EXISTS audit_log (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT,
action VARCHAR(50) NOT NULL,
table_name VARCHAR(100),
record_id INT,
details JSON,
ip_address VARCHAR(45),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

5. SSL/TLS Configuration

Certificate Issuance (Let's Encrypt + Certbot)

# Install certbot (Oracle Linux 9)
sudo dnf install -y certbot python3-certbot-nginx

# Obtain certificate
sudo certbot certonly --nginx \
-d loftygoldenoil.com \
-d www.loftygoldenoil.com \
--email admin@loftygoldenoil.com \
--agree-tos \
--no-eff-email

# Certificates stored at:
# /etc/letsencrypt/live/loftygoldenoil.com/fullchain.pem
# /etc/letsencrypt/live/loftygoldenoil.com/privkey.pem

Auto-Renewal

# Test renewal
sudo certbot renew --dry-run

# Certbot installs a systemd timer for automatic renewal
systemctl status certbot-renew.timer

# Manual renewal
sudo certbot renew
sudo systemctl reload nginx

Certificate Renewal Crontab

# Check existing crontab
crontab -l

# Add renewal check (certbot timer handles this automatically)
# 0 3 * * * /usr/bin/certbot renew --quiet && /usr/bin/systemctl reload nginx

Staging SSL

For the staging server at 170.9.14.60:8443, self-signed certificates are used:

# Generate self-signed certificate
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout /etc/ssl/private/lgo-staging.key \
-out /etc/ssl/certs/lgo-staging.crt \
-subj "/CN=170.9.14.60"

6. Vite Configuration

File: client/vite.config.js

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
plugins: [
react(),
],

// Development server
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:4000', // Proxy API calls to Express
changeOrigin: true,
secure: false,
},
},
},

// Build configuration
build: {
outDir: 'dist',
sourcemap: false, // No sourcemaps in production
minify: 'terser',
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom'], // Separate vendor bundle
},
},
},
},

// Resolve aliases
resolve: {
alias: {
'@': '/src',
},
},
});

Build Commands

npm run build # Production build → client/dist/
npm run preview # Preview production build locally
npm run dev # Development server with HMR

7. Tailwind Configuration

File: client/tailwind.config.js

/** @type {import('tailwindcss').Config} */
export default {
content: [
'./index.html',
'./src/**/*.{js,jsx,ts,tsx}',
],
darkMode: 'class', // Toggle via .dark class on <html>
theme: {
extend: {
colors: {
primary: {
DEFAULT: '#D4A843', // Gold — brand primary
hover: '#C49A38', // Gold hover
light: '#E8C96D', // Light gold
},
cream: {
DEFAULT: '#FDF8F0', // Light background
dark: '#F5EDE0', // Slightly darker cream
},
dark: {
DEFAULT: '#121212', // Dark mode background
surface: '#1E1E1E', // Dark mode surface
border: '#333333', // Dark mode borders
},
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
},
},
},
plugins: [],
};

CSS Variables (in client/src/index.css)

@tailwind base;
@tailwind components;
@tailwind utilities;

:root {
--color-primary: #D4A843;
--color-primary-hover: #C49A38;
--color-bg: #FDF8F0;
--color-surface: #FFFFFF;
--color-text: #1A1A1A;
--color-text-secondary: #6B7280;
--color-border: #E5E7EB;
}

.dark {
--color-primary: #D4A843;
--color-primary-hover: #E0B94E;
--color-bg: #121212;
--color-surface: #1E1E1E;
--color-text: #F5F5F5;
--color-text-secondary: #9CA3AF;
--color-border: #333333;
}

body {
background-color: var(--color-bg);
color: var(--color-text);
font-family: 'Inter', system-ui, sans-serif;
}

8. Application Settings

The settings table stores application-wide configuration as key-value pairs. These are managed through the Settings panel in the UI.

Settings Table Schema

CREATE TABLE IF NOT EXISTS settings (
id INT AUTO_INCREMENT PRIMARY KEY,
setting_key VARCHAR(100) UNIQUE NOT NULL,
setting_value TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

Default Settings

KeyDefault ValueDescription
company_nameLofty Golden Oil LtdCompany name displayed in reports and headers
company_addressLagos, NigeriaCompany address
company_emailinfo@loftygoldenoil.comCompany contact email
company_phone+234XXXXXXXXXXCompany phone number
tax_rate7.5VAT/tax rate percentage
low_stock_threshold100Default low stock alert threshold (litres)
currencyCurrency symbol
currency_codeNGNISO currency code
email_fromnoreply@loftygoldenoil.comDefault sender email
invoice_prefixINVInvoice number prefix
receipt_prefixRCPReceipt number prefix
fiscal_year_start01-01Fiscal year start (MM-DD)
date_formatDD/MM/YYYYDefault date display format
timezoneAfrica/LagosApplication timezone

Reading Settings in Code

// In server.js
const [settings] = await pool.query('SELECT * FROM settings');
const settingsMap = settings.reduce((acc, row) => {
acc[row.setting_key] = row.setting_value;
return acc;
}, {});

// Access a setting
const taxRate = parseFloat(settingsMap.tax_rate || '7.5');
const companyName = settingsMap.company_name || 'Lofty Golden Oil Ltd';

Updating Settings

// API endpoint (admin only)
app.put('/api/settings', authenticateToken, requireRole(['admin']), async (req, res) => {
try {
const updates = req.body; // { key: value, key: value }

for (const [key, value] of Object.entries(updates)) {
await pool.query(
'INSERT INTO settings (setting_key, setting_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE setting_value = ?',
[key, value, value]
);
}

res.json({ message: 'Settings updated' });
} catch (err) {
console.error('Error updating settings:', err);
res.status(500).json({ message: 'Failed to update settings' });
}
});

9. API Configuration

Base URL Patterns

EnvironmentURLProtocol
Productionhttps://loftygoldenoil.com/apiHTTPS
Staginghttp://170.9.14.60:8443/apiHTTP
Developmenthttp://localhost:4000/apiHTTP

CORS Configuration

// server.js — CORS is configured to allow all origins
app.use(cors());

Note: CORS is fully open. This is acceptable for the current deployment since the API is only accessed by the frontend. For enhanced security, restrict to specific origins:

app.use(cors({
origin: [
'https://loftygoldenoil.com',
'http://170.9.14.60:8443',
'http://localhost:5173', // development
],
credentials: true,
}));

Body Size Limits

app.use(express.json({ limit: '15mb' }));
app.use(express.urlencoded({ extended: true, limit: '15mb' }));

The 15MB limit accommodates large CSV imports and bulk operations. The Nginx client_max_body_size must match:

client_max_body_size 15m;

JWT Configuration

PropertyValue
AlgorithmHS256
Expiry24 hours (24h)
Secretprocess.env.JWT_SECRET
Payload&#123; id, username, role, email &#125;
// Token generation
const token = jwt.sign(
{ id: user.id, username: user.username, role: user.role, email: user.email },
process.env.JWT_SECRET,
{ expiresIn: '24h' }
);

// Token verification
const decoded = jwt.verify(token, process.env.JWT_SECRET);

Password Hashing

PropertyValue
Librarybcryptjs
Rounds10
Hash length60 characters
// Hash password
const hashedPassword = await bcrypt.hash(password, 10);

// Verify password
const isValid = await bcrypt.compare(inputPassword, storedHash);

10. Logging

PM2 Log Locations

ProcessOutput LogError Log
lgo-prod/home/opc/logs/lgo-prod-out.log/home/opc/logs/lgo-prod-error.log
lgo-staging/home/opc/logs/lgo-staging-out.log/home/opc/logs/lgo-staging-error.log

View Logs

# Tail all PM2 logs
pm2 logs

# Tail specific process
pm2 logs lgo-prod
pm2 logs lgo-staging

# View last 100 lines
pm2 logs lgo-prod --lines 100

# View error logs only
pm2 logs lgo-prod --err

Nginx Log Locations

LogPath
Access log/var/log/nginx/access.log
Error log/var/log/nginx/error.log
# Tail Nginx access log
tail -f /var/log/nginx/access.log

# Tail Nginx error log
tail -f /var/log/nginx/error.log

MySQL Log Locations

LogPath
Slow query log/var/log/mysql/slow.log
General logDisabled by default
Binary log/var/lib/mysql/mysql-bin.*

Application Logging

The application uses console.log and console.error which PM2 captures to the log files above.

// Info logging
console.log(`User ${req.user.username} accessed /api/inventory`);

// Error logging
console.error('Error fetching inventory:', err);

// Audit logging (to database)
await pool.query(
'INSERT INTO audit_log (user_id, action, table_name, record_id, details, created_at) VALUES (?, ?, ?, ?, ?, NOW())',
[req.user.id, 'CREATE', 'inventory', result.insertId, JSON.stringify({ name })]
);

Log Rotation

PM2 does not include built-in log rotation. Configure logrotate or use the pm2-logrotate module:

# Install log rotation module
pm2 install pm2-logrotate

# Configure
pm2 set pm2-logrotate:max_size 10M # Rotate at 10MB
pm2 set pm2-logrotate:retain 7 # Keep 7 rotated files
pm2 set pm2-logrotate:compress true # Compress rotated files
pm2 set pm2-logrotate:dateFormat YYYY-MM-DD_HH-mm-ss
pm2 set pm2-logrotate:rotateInterval '0 0 * * *' # Daily at midnight

Structured Logging Recommendation

For production observability, consider upgrading to structured JSON logging:

// Recommended: use a logger library
const logger = {
info: (msg, meta = {}) => console.log(JSON.stringify({ level: 'info', message: msg, ...meta, timestamp: new Date().toISOString() })),
error: (msg, meta = {}) => console.error(JSON.stringify({ level: 'error', message: msg, ...meta, timestamp: new Date().toISOString() })),
};

logger.info('Inventory fetched', { userId: req.user.id, count: rows.length });
logger.error('Failed to fetch inventory', { error: err.message, userId: req.user.id });