Architecture
The whole stack: one machine, two Cloudflare-fronted hostnames, two systemd services, one git repo, one SQLite DB.
Big picture
GitHub (valinor-intra)
│
│ cron: every 5 min
▼
┌─────────────────────────┐
│ ValinorPC (Ubuntu 24) │
│ │
┌─────────────────┤ cloudflared (tunnel) │
│ intra. │ │ │ laurelin.
│ valinorinfo.com │ │ │ valinorinfo.com
│ │ ▼ │
│ ┌────────────────────────────────┐ │
│ │ Nginx :80 (local) │ │
│ │ static + jsx-wrapped HTML │ │
│ └──────────────┬─────────────────┘ │
│ │ proxies /api/* │
│ ▼ │
│ ┌────────────────────────────────┐ │
│ │ valinor-intra.service :3000 │◀──┤
│ │ Node + better-sqlite3 │ │
│ │ (server.js) │ │
│ └──────────────┬─────────────────┘ │
│ │ │
│ ▼ │
│ /var/www/team-site/data/ │
│ laurelin.sqlite (WAL) │
│ ▲ │
│ │ │
│ ┌──────────────┴─────────────────┐ │
│ │ laurelin-sync.service │ │
│ │ Node (sync-worker.js) │ │
│ │ polls Outlook/Telegram/Notion │ │
│ └────────────────────────────────┘ │
│ │
└───────────────────────────────────────────┘
▲
│ both URLs gated by
Cloudflare Access (@valinordigital.com)
▲
│
Team
Hosts
ValinorPC
- OS: Ubuntu 24
- Local IP: 192.168.247.200
- CPU: small desktop-class; CRM workload is bounded
- Disk: SSD, ~1TB; SQLite DB is well under 1GB
A real machine in the office. No public IP, no router ports open. The only path in is the Cloudflare Tunnel established by cloudflared.
No staging
There's no staging environment. Changes go to main → cron picks them up → they're live within 5 minutes. The mitigation is:
- The cron pull only runs
deploy.shifgit diff --quiet HEAD origin/mainis false. So pushes that don't touch deployed files are no-ops. - Pre-deploy DB snapshots (
scripts/backup-laurelin.sh pre-deploy-<timestamp>) capture the DB before any restart that involves schema changes. - Daily 3am DB snapshots as the second safety net.
deploy.shverifiessystemctl is-active valinor-intraafter a restart and surfaces failures in the deploy log.
For risky changes (schema rebuilds, route refactors), the convention is "snapshot first, push during quiet hours, watch the deploy log."
The two access paths
Both hostnames share the same Cloudflare Tunnel and the same Access policy (@valinordigital.com). They differ only in what they route to inside the tunnel.
The ingress map in /etc/cloudflared/config.yml is the source of truth for routing. Both hostnames point at http://localhost:3000 (the Node server).
intra.valinorinfo.com — the intranet
Tunnel target: http://localhost:3000 (the Node server, server.js). Node serves the static files and JSX-wrapped HTML out of /var/www/team-site/ and also handles /api/*. There is no separate proxy hop. Node sets Cache-Control: no-cache on .html responses, so a fixed HTML page is not pinned in a browser or at the Cloudflare edge.
Nginx exists on the box (
/etc/nginx/sites-available/team-site, web root/var/www/team-site/) and can serve the same files onlocalhost:80, but the tunnel does NOT route through it. If you are chasing a serving or caching bug, look at Node on port 3000, not Nginx. A 2026-06-22 outage burned hours on the wrong assumption that Nginx was in the path. It was not. See troubleshooting.md.
laurelin.valinorinfo.com — the CRM hostname
Tunnel target: http://localhost:3000 (same Node server). The Laurelin bundle lives at https://laurelin.valinorinfo.com/apps/laurelin.html. Use this hostname when you specifically want the CRM. Most team members bookmark it.
Same files, same backend, same Node process as the intranet hostname. Two hostnames, one room. The split is for convenience: a short URL for the CRM plus a clear label in the address bar.
A second Cloudflare tunnel named valinor-intra (152319d5…) still exists in the account but has no active connections. It is leftover and unused. The live tunnel is laurelin (7a19dd55…).
Same DB, same backend, same Laurelin. Two hostnames, one room. Both gated by the same Cloudflare Access policy.
Why Cloudflare and not Tailscale
Earlier the intranet was Tailscale-gated (http://100.95.70.13 on the tailnet). That required every team member to install and maintain the Tailscale client. Moving to Cloudflare Tunnel + Access means:
- No client to install.
- Works on phones, tablets, browsers anywhere.
- Same auth (Cloudflare-emailed code to
@valinordigital.com) covers both intranet and Laurelin. - ValinorPC stays just as locked down — no public IP, no inbound ports.
cloudflareddials out to Cloudflare; Cloudflare proxies inbound through that dial-out.
If you find any remaining reference to Tailscale, 100.95.70.13, or "tailnet" in the codebase, it's stale — file a doc-fix.
Repo structure
valinor-intra/
├── README.md ← top-level (mostly superseded by /docs)
├── CLAUDE.md ← agent rules + canonical schema table
├── index.html ← intranet homepage
├── server.js ← Node entry — http.createServer
├── deploy.sh ← runs on ValinorPC via cron
├── package.json
├── api/
├── apps/ ← JSX apps (auto-wrapped) + laurelin.entry.jsx (esbuild)
├── assets/ ← brand SVGs
├── data/ ← seed JSON for fresh deploys
├── deploy/ ← systemd unit files
├── docs/ ← these docs (rendered to /var/www/team-site/docs/)
├── laurelin/ ← Laurelin backend module
│ ├── db.js
│ ├── routes.js
│ ├── seed.js
│ ├── sync-worker.js
│ └── sync/
├── recruiting/ ← Recruiting backend module
├── requests/ ← intranet request queue (filesystem-based)
├── scripts/ ← backup-laurelin.sh, generate-*-docs.js, render-docs.js
├── static/ ← partnership review HTML
└── process-queue.sh ← processes the request queue
The deploy flow
git push origin main
│
▼
[5-min cron on ValinorPC]
│
▼
git fetch origin main
git diff --quiet HEAD origin/main ── if true, no-op and exit
│
▼
./deploy.sh
│
├── npm install --production
├── copy laurelin/, recruiting/, package.json, node_modules → /var/www/team-site/
├── copy index.html, static/*, assets/, apps/rubrics/, apps/library/, apps/proposals/
├── esbuild apps/laurelin.entry.jsx → apps/laurelin.js (with content-hash cache bust)
├── for each other apps/*.jsx: wrap with React+Babel header/footer → apps/*.html
├── generate manifest.json (companies + apps)
├── node scripts/generate-schema-docs.js → docs/reference/schema.md
├── node scripts/generate-api-docs.js → docs/reference/api.md
├── node scripts/render-docs.js /var/www/team-site/docs → HTML + docs-manifest.json
├── seed data/*.json → /var/www/team-site/data/ (only if target doesn't exist)
├── if backend file newer than service start time:
│ bash scripts/backup-laurelin.sh pre-deploy-<timestamp>
│ sudo systemctl restart valinor-intra
│ verify with systemctl is-active
├── install or refresh laurelin-sync.service unit if changed
└── if sync file newer than service start time:
sudo systemctl restart laurelin-sync
│
▼
git pull, /var/www/team-site/, services running
│
▼
Team browser refreshes → sees changes (next hard-refresh)
The full script is at the top of the repo: deploy.sh. Reading it once is faster than reading this summary.
JSX wrapping
deploy.sh has two code paths for frontend apps:
Babel-standalone (legacy, used by every app except Laurelin):
For each .jsx in apps/:
- Generate an HTML wrapper that loads React 18 + Babel-standalone from a CDN.
- Strip the leading
importline from the JSX. - Convert
export default function Foo()→function Foo(). - Inline the JSX as
<script type="text/babel">…</script>. - Emit
apps/<name>.html.
The wrapper provides useState, useEffect, useRef, useCallback, useMemo implicitly (destructured from React in the header). That's the whole import budget — anything else, you write yourself.
esbuild (Laurelin only):
apps/laurelin.entry.jsx is the bundle entrypoint. deploy.sh runs:
./node_modules/.bin/esbuild apps/laurelin.entry.jsx \
--bundle --minify --target=es2020 --jsx=automatic --loader:.jsx=jsx \
--outfile=/var/www/team-site/apps/laurelin.js
And emits laurelin.html referencing laurelin.js?v=<contenthash> for cache-busting.
Why the split: Laurelin is large enough that Babel-standalone parsing in the browser was taking ~30s first load. esbuild gets that down to ~3s. Every other app is small enough that Babel is fine.
The systemd units
valinor-intra.service
Runs node server.js. Listens on port 3000 (local-only). Nginx proxies /api/* from either hostname to localhost:3000 — both intra.valinorinfo.com and laurelin.valinorinfo.com tunnel into Nginx, and the static + API split is handled inside Nginx.
Unit file lives at /etc/systemd/system/valinor-intra.service. Restarts on file changes (deploy script handles this with a freshness check, not a watch loop).
deploy.sh requires NOPASSWD sudo for systemctl restart valinor-intra — set in /etc/sudoers.d/valinor-deploy.
laurelin-sync.service
Runs node laurelin/sync-worker.js. Polls Outlook/Telegram/Notion, drives the signature parser and lost-thread detector. Decoupled from the API service so an API restart doesn't lose mid-poll state.
Unit file source: deploy/laurelin-sync.service in the repo. deploy.sh installs/refreshes it.
One-time enable on a new host:
sudo systemctl enable --now laurelin-sync
Database
/var/www/team-site/data/laurelin.sqlite — the one DB. WAL mode. Foreign keys on. Synchronous access via better-sqlite3.
Three files on disk during normal operation:
laurelin.sqlite ← main DB
laurelin.sqlite-wal ← write-ahead log
laurelin.sqlite-shm ← shared memory
Never cp laurelin.sqlite on its own — under write load you get a corrupt snapshot. Use scripts/backup-laurelin.sh, which uses the SQLite .backup command (correct under concurrent writes).
The DB is not in the git repo — it lives only on ValinorPC. Loss of the machine means loss of the DB unless backups exist (they do — see Operations → backups).
State of play summary
- One server, on-prem, reachable only via Cloudflare Tunnel.
- Two Cloudflare-Access-gated hostnames (
intra.valinorinfo.com,laurelin.valinorinfo.com), both authenticating against@valinordigital.com. - Two systemd services: API + sync worker, decoupled.
- One SQLite DB, WAL-mode, backed up nightly + pre-deploy.
- Cron-driven deploy from
main, no staging, blast radius bounded by snapshots + restart verification. - All deps frozen: better-sqlite3, esbuild, react, react-dom, marked. No web framework.
Next: Operations for the day-to-day knobs.