Skip to content

Infrastructure

Regulus uses a composable, cloud-agnostic infrastructure designed to run at ~$5–30/month during Alpha and scale to $500/month at 50k MAU without architecture changes.


Service Map

ServiceProviderPurposeNotes
APISingle VPS (regulus)NestJS modular monolith under PM2 (regulus-api)nginx → api.rgls.uk, port 4101
Real-timeSame VPSCentrifugo self-hostedws.rgls.uk
DatabasePostgres 16 (Docker, port 5433) on same VPSPrimary DB + pgvectorLocal, no PgBouncer yet
AuthCustom JWT via NestJSInvite-only register flowIssued by apps/api, no Supabase Auth
StorageCloudflare R2Media uploads + DB backups (separate bucket)Zero egress for video
Admin panelSame VPS, nginx-served Vite SPA/var/www/regulus-app/admin/distadmin.rgls.uk. No Vercel.
DocsSame VPS, nginx-served VitePress build/var/www/regulus-app/docs/diststatic
MobileExpo EASiOS + Android OTA updatesEAS Build + EAS Submit
PushExpo Push APIMobile push notifications600 notif/sec capacity
EmailResend (or SMTP)Transactional emailWelcome, invite-used, digest
ObservabilityPostHog + SentryAnalytics + error trackingFree tiers

API — VPS + PM2

Repository: apps/api/Host: regulus (204.168.160.114), root, SSH key in ~/.ssh/regulus_deploy.

Layout on the box:

/opt/regulus-app/
  repo/                        # rsync target (full monorepo, no .git)
    apps/api/dist/             # compiled NestJS bundle (entry: dist/main.js)
    apps/api/prisma/migrations # for `prisma migrate deploy`
    node_modules/              # installed once, reused across deploys
  ecosystem.config.cjs         # PM2 spec for regulus-api
  logs/                        # api.out.log, api.err.log
  admin-dist/                  # admin bundle, kept as a stage copy

Deploy (from your laptop):

bash
# 1. Build everything locally
pnpm build

# 2. Sync compiled bundle + prisma migrations + domain dist
rsync -avz --delete apps/api/dist/      regulus:/opt/regulus-app/repo/apps/api/dist/
rsync -avz --delete apps/api/prisma/    regulus:/opt/regulus-app/repo/apps/api/prisma/ --exclude='migration_lock.toml'
rsync -avz --delete packages/domain/dist/ regulus:/opt/regulus-app/repo/packages/domain/dist/

# 3. Migrate + regenerate Prisma client + restart on server
#    NOTE: `prisma generate` is mandatory whenever schema.prisma changed —
#    otherwise the existing @prisma/client in node_modules will reject any
#    new fields ("Unknown field X on UserCountOutputType" / 500).
ssh regulus 'cd /opt/regulus-app/repo/apps/api \
  && pnpm prisma migrate deploy \
  && pnpm prisma generate \
  && pm2 restart regulus-api'

Health check: GET /health{ status: "ok", uptime, version } (outside /api/v1 prefix). Public: https://api.rgls.uk/health. PM2 binds the process to localhost:4101; nginx terminates TLS (Cloudflare in front).

nginx vhosts (/etc/nginx/sites-enabled/):

SiteServesUpstream / root
regulus-app-apiapi.rgls.ukproxy → localhost:4101
regulus-app-adminadmin.rgls.ukstatic /var/www/regulus-app/admin/dist
regulus-app-docsdocsstatic /var/www/regulus-app/docs/dist
regulus-app-wsws.rgls.ukproxy → Centrifugo /connection/websocket

Centrifugo — same VPS

Self-hosted real-time server. Users subscribe to:

  • user:{userId} — personal notifications channel
  • feed:public — new public post IDs
  • conversation:{conversationId} — message delivery (planned)

Runs as a separate PM2 / systemd unit; nginx vhost regulus-app-ws proxies wss://ws.rgls.uk/connection/websocket. Config via env — see Environment.


Postgres — Docker on the VPS

  • Postgres 16 with pgvector, running in Docker on the same box, bound to host port 5433.
  • Prod and dev run the same major version. No Supabase, no managed DB.
  • Connection via DATABASE_URL. No PgBouncer yet (single-box, low connection count).
  • Migrations applied with prisma migrate deploy on deploy (see API section).

Backups

A cron on the VPS runs scripts/ops/backup-db.sh, which pg_dumps the database and uploads the dump to a private R2 bucket (separate from the media bucket). Restores pull the latest object back and pg_restore.


Cloudflare R2

Two buckets, zero egress, served via Cloudflare CDN:

  • R2_BUCKET (e.g. regulus-media) — user media (photos/videos/audio), presigned-URL upload.
  • Backup bucket — private; nightly DB dumps from the backup cron only.

CORS allows rgls.uk + *.regulus.app. A server cron deletes pending media assets older than 24 h.

Media upload flow:

  1. Client requests a presigned PUT URL: POST /media/upload{ uploadUrl, key }.
  2. Client uploads the object directly to R2.
  3. Client commits: POST /media/commit { key } → flips the MediaAsset row to committed.

Admin Panel — same VPS, nginx-served Vite SPA

Repository: apps/admin/ (Vite + React 19 + Ant Design — not Next.js). Public URL: https://admin.rgls.ukPath on server: /var/www/regulus-app/admin/dist

API base URL is hard-coded to https://api.rgls.uk in apps/admin/src/lib/api.ts (no build-time env needed for prod).

Deploy (from your laptop):

bash
pnpm --filter @regulus/admin build
rsync -avz --delete apps/admin/dist/ regulus:/opt/regulus-app/admin-dist/
ssh regulus 'rsync -a --delete /opt/regulus-app/admin-dist/ /var/www/regulus-app/admin/dist/'

No restart needed — nginx (/etc/nginx/sites-enabled/regulus-app-admin) serves the static SPA directly. Cloudflare sits in front of nginx for TLS + real-IP.


Expo EAS — Mobile

Repository: apps/mobile/

Two build profiles in eas.json: preview (internal distribution) and production (store builds). The production profile auto-submits to the App Store / Play after a successful build.

bash
eas build --platform all --profile preview        # internal QA build
eas build --platform all --profile production      # store build + auto-submit
eas update --channel production --message "..."    # OTA JS-only update

runtimeVersion & OTA

runtimeVersion in apps/mobile/app.config.ts is an explicit string, deliberately decoupled from the app version (0.1.0). It gates which binaries an OTA can reach: eas update --channel production is only served to installs with the same runtimeVersion. Bump it by hand whenever the native layer changes (new/upgraded native module, config-plugin change, SDK upgrade) so OTAs never cross a native boundary.

runtimeVersionNative change
1.0.0Baseline
1.1.0Added expo-video (#VideoVisit)
1.3.0Added expo-audio (#AudioPost)
1.4.0Added expo-image (disk-cached avatars/media) — current

A JS-only fix can ship as an OTA; anything touching native requires a new build at the bumped runtime, and the OTA only reaches matching binaries.


Cost Breakdown (Alpha)

ServiceMonthly Cost
VPS (API + admin + Centrifugo + Postgres)~$10–25
Cloudflare R2~$0 (free 10 GB)
Cloudflare DNS/TLS$0
Expo EAS$0 (free tier)
Resend (email)$0–5
PostHog + Sentry$0 (free tiers)
Total~$5–40

Regulus — invite-only social-knowledge platform