Administration and operations
Hosting and installation
How the platform runs, what a server needs, and how to install it on Ubuntu with a dedicated system user, PostgreSQL, PM2, nginx, HTTPS and basic hardening.
On this page
How it runs#
- One Node.js process (a Next.js 16 app) serves the rider app, the admin panel, the API and these docs. It runs as the system user
lmsunder PM2, listening on127.0.0.1:3000only. The systemd unitpm2-lmsstarts it after a reboot. - nginx answers on port 443 (HTTPS), adds security headers and forwards requests to the app. Port 80 only redirects to HTTPS and serves Let’s Encrypt challenges. certbot (snap) keeps the certificate renewed.
- PostgreSQL on the same server holds all data. It listens on
127.0.0.1only. - cron runs the daily reminders (as
lms) and the nightly database backup (aspostgres). - Outbound HTTPS to the Claude API (
api.anthropic.com) for AI features and, if connected, to the WhatsApp Cloud API (graph.facebook.com).
| What | Where |
|---|---|
| App folder | /var/www/boolanga-lms |
| Previous release, for rollback | /var/www/boolanga-lms-prev |
| Settings | /var/www/boolanga-lms/.env |
| nginx site | /etc/nginx/sites-available/rider-academy |
| HTTPS certificate | /etc/letsencrypt/live/<SERVER_IP or domain>/ |
| Reminders log | /var/log/rider-academy-reminders.log |
| Database backups | /var/backups/boolanga-lms |
Run exactly one app process
ecosystem.config.cjs runs one process.Server requirements#
| Item | Requirement |
|---|---|
| Operating system | Ubuntu 24.04 LTS (the tested setup). |
| Size | 2 vCPU and 4 GB RAM for pilots, plus 2 GB of swap on small servers. Not load-tested at full rider volume yet. |
| Node.js | 22 or newer, with PM2 installed globally. |
| Database | PostgreSQL (the Ubuntu 24.04 package). |
| Web server | nginx, with certbot 5 from snap for Let’s Encrypt certificates. |
| Security tools | ufw firewall and fail2ban. |
| Network in | SSH, 80 and 443. |
| Network out | HTTPS to api.anthropic.com, and graph.facebook.com if WhatsApp is connected. |
| Domain | Recommended: a DNS name such as academy.example.com. Without one, HTTPS uses a short-lived Let’s Encrypt certificate for the server’s IP address. |
| Region | Near the riders for data residency, for example AWS Bahrain (me-south-1) or UAE (me-central-1). |
Always use PostgreSQL on a server
DATABASE_URL is empty, the app uses a built-in embedded database in data/pglite. It is only for previews on a laptop: it allows one process and can be damaged if the process is killed, which happens during server restarts.Install on a new server#
These steps match DEPLOY.md in the project. Run them as a user with sudo rights. Replace academy.example.com, you@your-server and every password with your own.
Install the server packages.
bashsudo apt update && sudo apt upgrade -y sudo apt install -y nginx postgresql fail2ban curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - sudo apt install -y nodejs sudo npm install -g pm2On a server with 4 GB of RAM or less, add 2 GB of swap so the build doesn’t run out of memory:
bashsudo fallocate -l 2G /swapfile sudo chmod 600 /swapfile sudo mkswap /swapfile sudo swapon /swapfile echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstabCreate the system user
lms. The app never runs as root.bashsudo adduser --system --group --home /home/lms --shell /bin/bash lmsCreate the database and its user.
bashsudo -u postgres psql -c "CREATE USER lms WITH PASSWORD 'choose-a-strong-password';" sudo -u postgres psql -c "CREATE DATABASE boolanga_lms OWNER lms;"Copy the app to
/var/www/boolanga-lms, owned bylms.Leave out
node_modules,.next,dataand any local.env:bash# on your computer, in the folder that contains boolanga-lms rsync -av --exclude node_modules --exclude .next --exclude data --exclude .env \ boolanga-lms/ you@your-server:/tmp/boolanga-lms/ # on the server sudo mv /tmp/boolanga-lms /var/www/boolanga-lms sudo chown -R lms:lms /var/www/boolanga-lmsCreate the settings file as lms.
bashsudo -iu lms cd /var/www/boolanga-lms cp .env.example .env chmod 600 .env nano .envSet at least
APP_URL,SESSION_SECRET,PIN_SECRET,DATABASE_URL,ANTHROPIC_API_KEY,INTEGRATION_API_KEY,ADMIN_EMAILandADMIN_PASSWORD. See Configuration.Still as lms: build, create the tables and the first admin, start.
bashnpm ci npm run build npm run db:migrate npm run db:seed -- --admin-only pm2 start ecosystem.config.cjs pm2 save exitBack as your sudo user, start PM2 at boot as
lms(creates the systemd unitpm2-lms).bashsudo pm2 startup systemd -u lms --hp /home/lmsSet up nginx. Until the certificate exists, comment out the 443 server block so nginx can start.
bashsudo mkdir -p /var/www/letsencrypt sudo cp /var/www/boolanga-lms/deploy/nginx.conf /etc/nginx/sites-available/rider-academy sudo nano /etc/nginx/sites-available/rider-academy # server_name and ssl_certificate paths: <SERVER_IP> or your domain sudo ln -s /etc/nginx/sites-available/rider-academy /etc/nginx/sites-enabled/ sudo rm -f /etc/nginx/sites-enabled/default sudo nginx -t && sudo systemctl reload nginxTurn on the firewall and fail2ban.
bashsudo ufw allow OpenSSH sudo ufw allow 'Nginx Full' sudo ufw enable sudo systemctl enable --now fail2ban # the sshd jail is on by default on UbuntuSchedule reminders and backups: see Scheduled jobs.
Get a certificate, enable the 443 block and set
APP_URLto https: see HTTPS.
Demo data only on demo servers
npm run db:seed (without --admin-only) adds about 320 fictional riders with PIN 1234, sample courses, and a second demo account ([email protected]) that shares the admin password. Never load it on a server with real riders. The seed does nothing if the database already has an admin.Install dev dependencies too
npm ci, not npm ci --omit=dev. The build and the db:migrate, db:seed and reminders scripts need them.Check the installation#
https://…/admin/loginloads without a certificate warning,http://redirects to it, and you can sign in withADMIN_EMAILandADMIN_PASSWORD./loginloads on a phone, and/docsshows this documentation.sudo -iu lms pm2 statusshowsboolanga-lmsonline, andsudo -iu lms pm2 logs boolanga-lms --lines 50shows no errors.- Creating a course from a small PDF works. This confirms the AI key.
- If you use the API:
GET /api/v1/completions?limit=1with the key returns 200. This also confirms the database connection.
HTTPS#
HTTPS is required: sign-in cookies are only marked Secure when APP_URL starts with https://, and one-tap links are built from APP_URL. Certificates come from Let’s Encrypt with certbot’s webroot method: nginx keeps running and serves the challenge files from /var/www/letsencrypt.
Install certbot 5 from snap. The apt package (certbot 2.9 on Ubuntu 24.04) can’t issue certificates for an IP address, so remove it if it’s installed:
sudo apt remove -y certbot python3-certbot-nginx
sudo snap install --classic certbot
sudo ln -sf /snap/bin/certbot /usr/bin/certbotWhere production is today
Option A: a domain (recommended)#
Point the domain’s DNS A record at the server.
Put the domain in
server_namein both server blocks of the nginx site file.Get a normal 90-day certificate.
bashsudo certbot certonly --webroot -w /var/www/letsencrypt -d academy.example.com --deploy-hook "systemctl reload nginx"Point
ssl_certificateandssl_certificate_keyat/etc/letsencrypt/live/academy.example.com/, make sure the 443 block is enabled, and reload nginx.bashsudo nginx -t && sudo systemctl reload nginxSet
APP_URL=https://academy.example.comin.envand restart the app.bashsudo -iu lms pm2 restart boolanga-lms --update-envUncomment the
Strict-Transport-Securityline in the 443 block and reload nginx.
Option B: the server’s IP address#
For a server without a domain, which is how production runs today.
Put
<SERVER_IP>inserver_namein both server blocks.Get a short-lived IP certificate, valid about 6 days.
bashsudo certbot certonly --webroot -w /var/www/letsencrypt --ip-address <SERVER_IP> --preferred-profile shortlived --deploy-hook "systemctl reload nginx"Point
ssl_certificateandssl_certificate_keyat/etc/letsencrypt/live/<SERVER_IP>/, enable the 443 block and reload nginx.Set
APP_URL=https://<SERVER_IP>in.envand restart the app.bashsudo -iu lms pm2 restart boolanga-lms --update-env
Leave HSTS commented out: browsers ignore it for IP addresses.
Renewal and checks#
certbot’s systemd timer snap.certbot.renew.timer renews certificates before they expire and reloads nginx through the deploy hook. The IP certificate renews every few days, so the timer and port 80 must stay available.
sudo certbot certificates # certificates, domain or IP, expiry dates
sudo certbot renew --dry-run # tests renewal without changing anything
systemctl list-timers snap.certbot.renew.timer # next scheduled runMoving from the IP address to a domain#
Follow Option A. One-tap links already sent contain the old address (https://<SERVER_IP>/l/…). They keep working as long as the IP address still serves the site over HTTPS, so keep a 443 server block for <SERVER_IP> with its certificate until those links have expired, 14 days after the switch. Then you can remove the IP certificate:
sudo certbot delete --cert-name <SERVER_IP>What the nginx site file contains#
| Setting | Why |
|---|---|
listen 80 | Serves /.well-known/acme-challenge/ from /var/www/letsencrypt, and redirects everything else to https (301). |
listen 443 ssl http2 | HTTPS with TLS 1.2 and 1.3, certificates from /etc/letsencrypt/live/<name>/, proxied to the app on 127.0.0.1:3000. |
client_max_body_size 25m | Course files (up to 20 MB) and rider CSV imports are uploaded through the app. |
proxy_buffering off | The assistant streams its answers word by word. |
proxy_read_timeout 300s | Reading PDFs for the assistant and large imports can take minutes. |
proxy_set_header Host / X-Forwarded-* | The app checks the host of form submissions and builds correct addresses. |
server_tokens off | Hides the nginx version. |
X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy | Security headers on every HTTPS response. |
Strict-Transport-Security | Commented out. Enable it on a domain; browsers ignore it for IP addresses. |
SSH access#
fail2ban blocks addresses that repeatedly fail SSH sign-in. We also recommend signing in with SSH keys only. After confirming key login works in a second session:
echo "PasswordAuthentication no" | sudo tee /etc/ssh/sshd_config.d/00-disable-passwords.conf
sudo systemctl reload ssh
sudo sshd -T | grep -i passwordauthentication # should print: passwordauthentication noMore in Hardening checklist.