Configuration Reference
Lofty Golden Oil ERP — All Configuration Options and Settings
Table of Contents
- Environment Variables
- Nginx Configuration
- PM2 Ecosystem Configuration
- MySQL Configuration
- SSL/TLS Configuration
- Vite Configuration
- Tailwind Configuration
- Application Settings
- API Configuration
- Logging
1. Environment Variables
All environment variables are defined in server/.env (not committed to version control).
| Variable | Required | Default | Description |
|---|---|---|---|
NODE_ENV | Yes | development | Runtime environment: development, staging, or production |
PORT | Yes | 4000 | Express server listening port |
DB_HOST | Yes | localhost | MySQL server hostname or IP |
DB_PORT | Yes | 3306 | MySQL server port |
DB_USER | Yes | lgo | MySQL username |
DB_PASSWORD | Yes | — | MySQL password |
DB_NAME | Yes | — | MySQL database name (lgo_erp_prod or lgo_erp_staging) |
JWT_SECRET | Yes | — | Secret key for HS256 JWT signing. Must be a long random string. |
RESEND_API_KEY | Yes | — | Resend HTTP API key for email delivery |
RESEND_FROM_EMAIL | Yes | — | Sender email address (must be verified in Resend) |
FRONTEND_URL | Yes | — | Frontend 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
.envfiles to version control - Use different
JWT_SECRETvalues for staging and production JWT_SECRETshould be at least 32 characters, randomly generated- Rotate
RESEND_API_KEYperiodically DB_PASSWORDshould 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
| Directive | Purpose |
|---|---|
try_files $uri $uri/ /index.html | SPA fallback — serves index.html for client-side routes |
proxy_pass http://lgo_backend | Forwards /api/* requests to the Express upstream |
proxy_set_header X-Real-IP | Passes client IP to Express for logging |
client_max_body_size 15m | Matches Express body parser limit |
gzip on | Compresses responses for faster loading |
ssl_session_cache shared:SSL:10m | Caches 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
| Option | Value | Purpose |
|---|---|---|
name | lgo-prod / lgo-staging | Process identifier for PM2 commands |
script | server/server.js | Entry point for the Express server |
instances | 1 | Single instance (VPS has 2 OCPU, 4GB RAM) |
exec_mode | fork | Single process mode (no clustering) |
PORT | 4000 / 8443 | Passed to Express via process.env.PORT |
max_memory_restart | 300M / 250M | Restart if memory exceeds limit |
autorestart | true | Auto-restart on crash |
max_restarts | 10 | Maximum restart attempts before giving up |
min_uptime | 5s | Process must run 5s to be considered "started" |
kill_timeout | 5000 | 5s to gracefully shut down before SIGKILL |
merge_logs | true | Combine 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
| Property | Production | Staging |
|---|---|---|
| Database Name | lgo_erp_prod | lgo_erp_staging |
| Host | localhost | localhost |
| Port | 3306 | 3306 |
| User | lgo | lgo |
| Charset | utf8mb4 | utf8mb4 |
| Connection Pool | 5 | 5 |
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;
Recommended my.cnf Settings
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
| Key | Default Value | Description |
|---|---|---|
company_name | Lofty Golden Oil Ltd | Company name displayed in reports and headers |
company_address | Lagos, Nigeria | Company address |
company_email | info@loftygoldenoil.com | Company contact email |
company_phone | +234XXXXXXXXXX | Company phone number |
tax_rate | 7.5 | VAT/tax rate percentage |
low_stock_threshold | 100 | Default low stock alert threshold (litres) |
currency | ₦ | Currency symbol |
currency_code | NGN | ISO currency code |
email_from | noreply@loftygoldenoil.com | Default sender email |
invoice_prefix | INV | Invoice number prefix |
receipt_prefix | RCP | Receipt number prefix |
fiscal_year_start | 01-01 | Fiscal year start (MM-DD) |
date_format | DD/MM/YYYY | Default date display format |
timezone | Africa/Lagos | Application 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
| Environment | URL | Protocol |
|---|---|---|
| Production | https://loftygoldenoil.com/api | HTTPS |
| Staging | http://170.9.14.60:8443/api | HTTP |
| Development | http://localhost:4000/api | HTTP |
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
| Property | Value |
|---|---|
| Algorithm | HS256 |
| Expiry | 24 hours (24h) |
| Secret | process.env.JWT_SECRET |
| Payload | { id, username, role, email } |
// 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
| Property | Value |
|---|---|
| Library | bcryptjs |
| Rounds | 10 |
| Hash length | 60 characters |
// Hash password
const hashedPassword = await bcrypt.hash(password, 10);
// Verify password
const isValid = await bcrypt.compare(inputPassword, storedHash);
10. Logging
PM2 Log Locations
| Process | Output Log | Error 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
| Log | Path |
|---|---|
| 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
| Log | Path |
|---|---|
| Slow query log | /var/log/mysql/slow.log |
| General log | Disabled 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 });