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
| Service | Provider | Purpose | Notes |
|---|---|---|---|
| API | Single VPS (regulus) | NestJS modular monolith under PM2 (regulus-api) | nginx → api.rgls.uk, port 4101 |
| Real-time | Same VPS | Centrifugo self-hosted | ws.rgls.uk |
| Database | Postgres 16 (Docker, port 5433) on same VPS | Primary DB + pgvector | Local, no PgBouncer yet |
| Auth | Custom JWT via NestJS | Invite-only register flow | Issued by apps/api, no Supabase Auth |
| Storage | Cloudflare R2 | Media uploads + DB backups (separate bucket) | Zero egress for video |
| Admin panel | Same VPS, nginx-served Vite SPA | /var/www/regulus-app/admin/dist | admin.rgls.uk. No Vercel. |
| Docs | Same VPS, nginx-served VitePress build | /var/www/regulus-app/docs/dist | static |
| Mobile | Expo EAS | iOS + Android OTA updates | EAS Build + EAS Submit |
| Push | Expo Push API | Mobile push notifications | 600 notif/sec capacity |
| Resend (or SMTP) | Transactional email | Welcome, invite-used, digest | |
| Observability | PostHog + Sentry | Analytics + error tracking | Free 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 copyDeploy (from your laptop):
# 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/):
| Site | Serves | Upstream / root |
|---|---|---|
regulus-app-api | api.rgls.uk | proxy → localhost:4101 |
regulus-app-admin | admin.rgls.uk | static /var/www/regulus-app/admin/dist |
regulus-app-docs | docs | static /var/www/regulus-app/docs/dist |
regulus-app-ws | ws.rgls.uk | proxy → Centrifugo /connection/websocket |
Centrifugo — same VPS
Self-hosted real-time server. Users subscribe to:
user:{userId}— personal notifications channelfeed:public— new public post IDsconversation:{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 deployon 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:
- Client requests a presigned PUT URL:
POST /media/upload→{ uploadUrl, key }. - Client uploads the object directly to R2.
- Client commits:
POST /media/commit { key }→ flips theMediaAssetrow tocommitted.
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):
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.
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 updateruntimeVersion & 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.
| runtimeVersion | Native change |
|---|---|
1.0.0 | Baseline |
1.1.0 | Added expo-video (#VideoVisit) |
1.3.0 | Added expo-audio (#AudioPost) |
1.4.0 | Added 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)
| Service | Monthly 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 |