# Deploying the Hub How to run the Hub on the server at `events-hub.crosscode.co.za`, behind FASTPANEL's nginx and an frp tunnel. ``` Internet ──► frps (public VPS) ══ tunnel ══► frpc (this server, /etc/frp/frpc.ini) │ ▼ nginx (FASTPANEL, :80 / :443, HTTPS) ├── /api/* ──► backend (Express, 127.0.0.1:5200) └── /* ──► frontend (Next.js, 127.0.0.1:3200) │ PostgreSQL (localhost:5432) ``` Only nginx is reachable from outside. Ports 5200 and 3200 must **not** be added to frpc. Replace `` below with the system user FASTPANEL creates for the site. --- ## 1. Prerequisites (once per server) - Node 20+ (`node -v`), and PM2: `sudo npm install -g pm2` - PostgreSQL 14+ - Git access to this repo If Hope Events is already deployed on this server, you already have all of these. --- ## 2. DNS Add an `A` record for `events-hub.crosscode.co.za` pointing at the **public IP of the frps VPS**, not this server's IP. Check it with `dig +short events-hub.crosscode.co.za`. --- ## 3. frp Open `/etc/frp/frpc.ini`. How much you need to do depends on how it already forwards web traffic. **a) Plain TCP forwarding of 80 and 443** — you'll see something like: ```ini [web-http] type = tcp local_ip = 127.0.0.1 local_port = 80 remote_port = 80 [web-https] type = tcp local_ip = 127.0.0.1 local_port = 443 remote_port = 443 ``` Then **you don't need to change anything**: nginx picks the site by domain name. Go to step 4. **b) Per-domain `http` / `https` proxies** (`custom_domains = ...`, with `vhost_http_port` / `vhost_https_port` set on frps) — add a pair for the Hub: ```ini [events-hub-http] type = http local_ip = 127.0.0.1 local_port = 80 custom_domains = events-hub.crosscode.co.za [events-hub-https] type = https local_ip = 127.0.0.1 local_port = 443 custom_domains = events-hub.crosscode.co.za ``` Use `https` (passthrough), not `https2http`: TLS is ended by nginx on this server with the FASTPANEL certificate. Check the file and restart: ```bash sudo frpc verify -c /etc/frp/frpc.ini sudo systemctl restart frpc sudo journalctl -u frpc -n 30 --no-pager # look for "start proxy success" ``` Port 80 must reach nginx through the tunnel, or Let's Encrypt can't issue the certificate. --- ## 4. Database Create a database and user. In FASTPANEL: **Databases → Create**, choose PostgreSQL, name `events_hub`. Or from the shell: ```bash sudo -u postgres psql <<'SQL' CREATE USER events_hub WITH PASSWORD 'a-long-random-password'; CREATE DATABASE events_hub OWNER events_hub; SQL ``` Your connection string is then `postgresql://events_hub:a-long-random-password@localhost:5432/events_hub`. (URL-encode the password if it contains `@ : / ? #`.) --- ## 5. Site in FASTPANEL 1. **Sites → Create site**, domain `events-hub.crosscode.co.za`. Note the site's system user (``) and folder, e.g. `/var/www//data/www/events-hub.crosscode.co.za`. 2. **SSL certificates → Let's Encrypt** for the domain. Turn on the redirect from HTTP to HTTPS. 3. Leave the nginx config for step 8; the apps need to be running first. --- ## 6. Get the code and configure it Work as the site user so files end up with the right owner: ```bash sudo -iu cd /var/www//data/www/events-hub.crosscode.co.za git clone app cd app ``` ### Backend ```bash cd backend cp .env.example .env nano .env ``` Set at least: | Variable | Value | |---|---| | `DATABASE_URL` | the connection string from step 4 | | `FRONTEND_URL` | `https://events-hub.crosscode.co.za` | | `JWT_SECRET` | `openssl rand -base64 48` | | `HUB_ENCRYPTION_KEY` | `openssl rand -base64 48`. **Never change this** once installs have registered | | `HUB_SIGNING_KEY_FILE` | `./keys/hub-signing-key.pem` (the default) | | `TRUST_PROXY` | `1` | | `SMTP_*`, `ALERT_EMAIL` | your mail settings, so alerts get sent | ```bash chmod 600 .env npm ci npx prisma migrate deploy && npx prisma generate npm run generate-keys # only on the very first deploy; it refuses to overwrite npm run create-admin -- --email you@example.com --name "Your Name" ``` `generate-keys` prints the **public key**. Put it into Hope Events (`backend/src/hub/publicKey.js`) and release. Copy `keys/hub-signing-key.pem` somewhere safe and offline **now** (see step 11). ### Frontend `NEXT_PUBLIC_API_URL` is built into the frontend at build time, so set it **before** building: ```bash cd ../frontend echo "NEXT_PUBLIC_API_URL=https://events-hub.crosscode.co.za" > .env.local npm ci npm run build ``` Don't add `/api` to that URL: the frontend already calls paths like `/api/auth/login`. --- ## 7. Run with PM2 Still as ``, from the `app` folder: ```bash pm2 start npm --name events-hub-backend --cwd backend -- start pm2 start npm --name events-hub-frontend --cwd frontend -- start pm2 save ``` The names are different from Hope Events' `backend` / `frontend` processes, so the two don't clash on the same server. Start on boot (run once; it prints a `sudo` command to paste): ```bash pm2 startup systemd ``` Check: ```bash pm2 status pm2 logs events-hub-backend --lines 20 # expect "[hub] Listening on 5200" curl -s http://127.0.0.1:5200/api/health # {"ok":true} curl -sI http://127.0.0.1:3200 | head -1 # HTTP/1.1 200 OK ``` The backend refuses to start (and says why in the logs) if `DATABASE_URL`, `JWT_SECRET`, `HUB_ENCRYPTION_KEY` or the signing key is missing. Run **one** backend process only (no `-i max` / cluster mode): the background alert jobs run in-process and would fire once per copy. --- ## 8. nginx (FASTPANEL) In FASTPANEL, open the site → **Configuration files** (the nginx config of the site). Inside the `server { ... }` block that listens on 443, replace the default `location /` (the one serving static files / PHP) with: ```nginx location /api/ { proxy_pass http://127.0.0.1:5200; 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_read_timeout 60s; } location / { proxy_pass http://127.0.0.1:3200; 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; } ``` Leave the `listen`, `server_name`, `ssl_certificate` and `/.well-known/acme-challenge/` parts that FASTPANEL wrote alone, so certificate renewal keeps working. If FASTPANEL also generated an Apache backend or PHP-FPM for this site, turn it off in the site settings. The Hub doesn't use it. Save (FASTPANEL tests the config and reloads nginx). From the shell, you can check with: ```bash sudo nginx -t && sudo systemctl reload nginx ``` --- ## 9. Check it from outside ```bash curl -s https://events-hub.crosscode.co.za/api/health # {"ok":true} ``` Then open `https://events-hub.crosscode.co.za`, log in, and set up two-factor (required on first login). In **Settings**, check that the public key shown matches the one in Hope Events. Point Hope Events installs at the Hub with `HUB_URL=https://events-hub.crosscode.co.za` in their `backend/.env`. --- ## 10. Real client IPs through frp (recommended) With frp in front, nginx sees every connection as coming from frpc (`127.0.0.1`). So the backend thinks **all** visitors share one IP. That has two effects: - The login rate limit (20 tries / 15 min) counts everyone together. Someone hammering the login page also locks you out. - Audit log entries all show `127.0.0.1`. (The heartbeat limit is 30/min per IP, and installs call every 15 minutes, so heartbeats are fine up to a few hundred installs, even sharing one IP.) You can fix this with the PROXY protocol, which carries the real client IP through the tunnel. **This applies to every site on this nginx**: once a `listen` has `proxy_protocol`, *all* server blocks on that port must have it, and nothing can reach nginx on that port except through frp. Only do it if all your sites come in through frp. 1. In each web proxy section of `/etc/frp/frpc.ini` (80 and 443), add: ```ini proxy_protocol_version = v2 ``` 2. In nginx, add `proxy_protocol` to **every** `listen` on those ports. With FASTPANEL, that means every site's config, e.g. `listen 443 ssl proxy_protocol;`. Then add this once, in the `http {}` block (e.g. a file in `/etc/nginx/conf.d/realip.conf`): ```nginx set_real_ip_from 127.0.0.1; real_ip_header proxy_protocol; ``` 3. `sudo nginx -t && sudo systemctl reload nginx && sudo systemctl restart frpc` After that, `$remote_addr` is the real visitor, and with `TRUST_PROXY=1` the backend sees it too. If you'd rather not touch the other sites, skip this. The Hub works without it. --- ## 11. Backups Two things must survive the server: **the database** and **the signing key** (plus `.env`, because `HUB_ENCRYPTION_KEY` can't be recreated). - **Signing key and `.env`**: copy `backend/keys/hub-signing-key.pem` and `backend/.env` to an offline place (password manager, encrypted USB) once. They don't change. - **Database, daily**: if FASTPANEL's backup feature covers PostgreSQL for this site, turn it on. Otherwise, as ``, `crontab -e`: ```cron 30 2 * * * pg_dump "postgresql://events_hub:PASSWORD@localhost:5432/events_hub" -Fc -f "$HOME/backups/events_hub-$(date +\%a).dump" ``` (`mkdir -p ~/backups` first). That keeps one dump per weekday. Copy them off the server regularly. To restore a dump: `pg_restore --clean --no-owner -d "" events_hub-Mon.dump` --- ## 12. Updating ```bash sudo -iu cd /var/www//data/www/events-hub.crosscode.co.za/app git pull cd backend npm ci npx prisma migrate deploy && npx prisma generate pm2 restart events-hub-backend cd ../frontend npm ci npm run build pm2 restart events-hub-frontend ``` Installs keep working while the Hub is briefly down (licences are valid offline for 30 days). --- ## Troubleshooting | Symptom | Look at | |---|---| | Site doesn't load at all | `systemctl status frpc`, `journalctl -u frpc`; DNS points at the frps VPS; frps allows ports 80/443 | | Let's Encrypt fails | Port 80 isn't reaching nginx through frp (step 3) | | 502 Bad Gateway | `pm2 status`, `pm2 logs events-hub-backend` / `events-hub-frontend` | | Login page loads, but login fails with a network/CORS error | `FRONTEND_URL` in `backend/.env` (exact `https://` origin, no trailing slash); `NEXT_PUBLIC_API_URL` in `frontend/.env.local`, **then rebuild** the frontend | | "Too many attempts" for everyone | Shared IP through frp, see step 10 | | Installs fail signature checks | nginx must pass the body through unchanged. Don't add body-rewriting modules to `/api/` | | Backend exits on start | `pm2 logs events-hub-backend`: it names the missing variable or key |