ธีม
Self-hosting
Running this on the KKU VM, after moving off Cloudflare Pages
The app moved from Cloudflare Pages to a KKU virtual machine (Ubuntu, behind the university reverse proxy at samo.md.kku.ac.th → https://10.101.111.181). Supabase stays on Supabase Cloud — only the frontend hosting and the Discord notify function move onto the VM.
The single non-obvious thing about this move: Cloudflare Pages did three jobs that a plain Nginx static host does not. They must be replicated or they fail silently:
| Cloudflare mechanism | What it did | Replacement on the VM |
|---|---|---|
functions/notify.js (Pages Function) | served POST /notify for all Discord notifications | server/notify-server.mjs (Node) behind Nginx |
public/_headers | cache policy (HTML no-cache, assets immutable, build.json no-store) | add_header blocks in server/nginx-samo.conf |
public/_redirects | SPA fallback for /* and /admin/* | try_files blocks in server/nginx-samo.conf |
Miss /notify and every PR / Vital Sound / หนังสือโครงการ Discord ping silently no-ops. Miss the headers and iPad Safari pins users on a stale bundle. Miss the /admin/* fallback and an admin refresh serves the wrong app.
Layout on the VM
/home/ubuntu/samo-projects/samomdkkuweb # this repo (has .env.local);
# passport/ lives INSIDE it since
# the 2026-09-04 merge — one clone,
# one npm ci, one `npm run build`
/var/www/samo-web # Nginx root for the public+admin app
/var/www/passport # Nginx root for passport
/etc/samo-notify.env # notify secrets (chmod 600, root)
/etc/systemd/system/samo-notify.service # notify service
/etc/nginx/sites-available/default # from server/nginx-samo.confOne-time setup
Node 22 + repos (already done): clone both repos into
~/samo-projects,npm install, add each app's.env.local(only the publicVITE_SUPABASE_ANON_KEY+ URL — never a service_role key).Passport base path — already committed, in this repo at
passport/vite.config.js(there is no separate passport repo since 2026-09-04). Historically this said to commit it to the passport repo'svite.config.jsso a futuregit pulldoesn't clobber it.Notify service:
bashsudo cp server/samo-notify.env.example /etc/samo-notify.env sudo chown root:root /etc/samo-notify.env && sudo chmod 600 /etc/samo-notify.env sudo nano /etc/samo-notify.env # paste the DISCORD_* values from the # old Cloudflare Pages env vars sudo cp server/samo-notify.service /etc/systemd/system/ sudo systemctl daemon-reload sudo systemctl enable --now samo-notify journalctl -u samo-notify -f # confirm "listening on ... /notify"Nginx:
bashsudo cp server/nginx-samo.conf /etc/nginx/sites-available/default sudo nginx -t && sudo systemctl reload nginxSupabase + Google OAuth allow-list (easy to forget — breaks sign-in): add
https://samo.md.kku.ac.thto- Supabase → Authentication → URL Configuration (Site URL + Redirect URLs)
- Google Cloud Console → OAuth client → Authorized JavaScript origins + redirect URIs
Security: change the VM password (
passwd), switch SSH to key-only (PasswordAuthentication noin/etc/ssh/sshd_config, thensudo systemctl restart ssh). The IT-issued password was weak and should be considered burned.
Every deploy after that
bash
cd ~/samo-projects/samomdkkuweb
./server/deploy.sh # pull + build both apps + publish + restart notify + reload nginxSmoke tests
bash
curl -sk https://127.0.0.1/build.json # {"buildId":"..."} , header Cache-Control: no-store
curl -sk https://127.0.0.1/notify # {"ok":true,"service":"samo-notify"}
curl -sk -o /dev/null -w '%{http_code}\n' https://127.0.0.1/admin/ # 200 (admin index)Then from a browser (off-VPN, to confirm public reach): submit a test PR and verify the Discord message lands. If notify_log is enabled (migration 0055 + SUPABASE_* in the env file), select * from notify_log order by at desc limit 5; shows the outcome.
Notes
- The notify Node server reuses
functions/notify.jsunchanged — the same code the vitest suite covers.functions/package.json("type":"module") is what letsnodeimport it; the repo root stays CommonJS for Vite. - Discord webhook egress is now the KKU VM IP, not GAS's shared IP — the Cloudflare-1015 per-IP block that plagued the GAS era does not apply here.
- If the VM is only reachable on-campus/VPN, external students lose access — verify public reachability before decommissioning Cloudflare.
⚠️ A NEW environment needs the passport schema, or สถิติ breaks at runtime
public.analytics_overview() reads passport.activities and passport.certificates — Passport shares the Google account, so it shares the GAS quota this panel reports. A plpgsql body is not resolved at CREATE time, so a database without that schema applies every migration cleanly and then throws the first time someone opens สถิติ.
So when standing up a scratch project or restoring a partial dump, dump and restore --schema=public --schema=passport (which is what tools/dev-refresh.mjs does). If you deliberately want a public-only environment, guard the two arms with to_regclass('passport.activities') is not null in a NEW migration rather than deleting them. Write-up: docs/mistakes/postgres-schema.md.