Files
ims/docs/new-server-setup.md

7.0 KiB

New Server Setup Runbook — ims.setia.com.my

Target: Fresh Ubuntu 22.04 LTS server at ims.setia.com.my SSH port: 9321 Purpose: Host the IMS Next.js app with self-hosted PostgreSQL 16 (replacing Supabase cloud)

Run every command manually over SSH unless stated otherwise.


A. Connect & Initial Hardening

ssh -p 9321 setia@ims.setia.com.my
# Use the initial password provided by the server administrator. CHANGE IT IMMEDIATELY after first login.

Change the password on first login:

passwd

Update the system:

sudo apt-get update && sudo apt-get upgrade -y

B. Install Dependencies

Node.js 20 (via NodeSource)

curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs
node -v   # should print v20.x.x

PostgreSQL 16 + pgvector

sudo apt-get install -y postgresql-16 postgresql-16-pgvector
sudo systemctl enable postgresql
sudo systemctl start postgresql

nginx + certbot

sudo apt-get install -y nginx certbot python3-certbot-nginx
sudo systemctl enable nginx
sudo systemctl start nginx

ufw firewall

sudo apt-get install -y ufw

C. Firewall Rules

sudo ufw allow 9321/tcp   # SSH (custom port — do this BEFORE enabling ufw)
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw deny 5432/tcp    # Postgres: localhost only
sudo ufw enable
sudo ufw status

Warning: Always allow port 9321 before enabling ufw, or you will lock yourself out.


D. PostgreSQL Setup

Generate strong passwords first (run locally or in a separate shell):

openssl rand -base64 32   # use output as STRONG_PASSWORD_1 (app_user)
openssl rand -base64 32   # use output as STRONG_PASSWORD_2 (app_admin)

Record both passwords — they go into /var/www/ims/.env in step I.

Connect as the postgres superuser:

sudo -u postgres psql

Run the following SQL:

CREATE DATABASE ims;
\c ims

CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pgcrypto;

-- app_user: subject to RLS (mirrors Supabase anon/authenticated role)
CREATE ROLE app_user LOGIN PASSWORD '<STRONG_PASSWORD_1>';

-- app_admin: bypasses RLS (mirrors Supabase service-role)
CREATE ROLE app_admin LOGIN PASSWORD '<STRONG_PASSWORD_2>' BYPASSRLS;

GRANT CONNECT ON DATABASE ims TO app_user, app_admin;
-- Table-level grants are applied after schema load in Phase 1

Exit psql:

\q

Verify extensions loaded:

sudo -u postgres psql -d ims -c "SELECT extname FROM pg_extension WHERE extname IN ('vector','pgcrypto');"
# Expected: 2 rows

E. nginx Configuration

Create the site config:

sudo nano /etc/nginx/sites-available/ims

Paste the following:

# /etc/nginx/sites-available/ims
server {
    listen 80;
    server_name ims.setia.com.my;

    location / {
        proxy_pass http://127.0.0.1:3003/;
        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;
        client_max_body_size 200M;
    }
}

Enable the site and test:

sudo ln -s /etc/nginx/sites-available/ims /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

F. TLS (certbot)

DNS must already point ims.setia.com.my to this server's public IP before running certbot.

sudo certbot --nginx -d ims.setia.com.my
# Follow the interactive prompts.
# Certbot will auto-edit the nginx config to add HTTPS and an HTTP→HTTPS redirect.
sudo systemctl reload nginx

Auto-renewal is configured by certbot automatically. Verify:

sudo certbot renew --dry-run

G. App Directory + Evidence Storage

sudo mkdir -p /var/www/ims
sudo mkdir -p /var/lib/ims/evidence
sudo chown -R setia:setia /var/www/ims /var/lib/ims

H. systemd Service

Create the service unit:

sudo nano /etc/systemd/system/ims.service

Paste the following:

[Unit]
Description=IMS Next.js App
After=network.target postgresql.service

[Service]
Type=simple
User=setia
WorkingDirectory=/var/www/ims
ExecStart=/usr/bin/node .next/standalone/server.js
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=ims
EnvironmentFile=/var/www/ims/.env

[Install]
WantedBy=multi-user.target

Enable and start:

sudo systemctl daemon-reload
sudo systemctl enable ims
sudo systemctl start ims
sudo systemctl status ims

The service will fail to start until .env is populated (step I) and the app is deployed (Phase 9). This is expected.


I. .env File on Server

Create the file — never commit this to git:

nano /var/www/ims/.env

Template — fill ALL values before starting the service:

# /var/www/ims/.env

# Database (local PostgreSQL)
DATABASE_URL=postgres://app_user:<STRONG_PASSWORD_1>@127.0.0.1:5432/ims
DATABASE_URL_ADMIN=postgres://app_admin:<STRONG_PASSWORD_2>@127.0.0.1:5432/ims

# Auth
JWT_SECRET=<32+ byte random — openssl rand -base64 32>

# App
APP_URL=https://ims.setia.com.my
SITE_URL=https://ims.setia.com.my
CRON_SECRET=<random — openssl rand -base64 24>

# Storage (local filesystem)
EVIDENCE_DIR=/var/lib/ims/evidence
EVIDENCE_URL_SECRET=<random HMAC key — openssl rand -base64 32>

# Email (Brevo)
BREVO_API_KEY=<from Brevo dashboard>
BREVO_FROM_EMAIL=noreply@setia.com.my

# AI (server-side only)
ANTHROPIC_API_KEY=<from Anthropic>
GOOGLE_AI_API_KEY=<from Google AI Studio>

Lock down permissions:

chmod 600 /var/www/ims/.env

J. Cron Jobs (Register After Phase 9 Deploy)

Refer to docs/vps-cron.md (to be created in Phase 9) for application-level cron jobs (e.g. NADOPOD reminders, CAPA escalation).


K. Backup

Create the backup directory:

sudo mkdir -p /var/lib/ims/backups
sudo chown setia:setia /var/lib/ims/backups

Set up daily pg_dump backup (add after Phase 8):

sudo nano /etc/cron.d/ims-backup

Paste:

0 2 * * * setia pg_dump ims > /var/lib/ims/backups/ims-$(date +\%Y\%m\%d).sql

Backups land in /var/lib/ims/backups/. Consider adding off-server backup (S3, rclone) for disaster recovery.


L. Verify Installation

Run these checks after completing all steps above:

# PostgreSQL extensions
sudo -u postgres psql -d ims -c "SELECT extname FROM pg_extension WHERE extname IN ('vector','pgcrypto');"
# Expected: 2 rows

# nginx + TLS (before app deploy — 502 is expected here)
curl -I https://ims.setia.com.my
# Expected: HTTP/2 502 (before Phase 9 deploy) or HTTP/2 200 (after deploy)

# App service (after deploy)
sudo systemctl status ims

Next Steps

  • Phase 1: Load the database schema (migrations + RLS policies + table grants)
  • Phase 9: Deploy the Next.js app, register cron jobs (docs/vps-cron.md)