Deploying to a VPS
The deployment target is a single Linux VPS with Docker. That is not a compromise made for convenience — it is the only shape that fits two hard constraints in this codebase.
The database cannot be managed. infra/postgres/Dockerfile compiles pgvector and Apache AGE
from source, and AGE has to be loaded via shared_preload_libraries. No managed Postgres (Neon,
Supabase, RDS, …) will let you install Apache AGE or set that preload flag, so hosting the database
off-box means giving up the knowledge graph that Pillar 1 is built on. The compose file keeps the
preload flag in command: rather than the image precisely so a deployment target can see and
override it.
apps/workflow is not a request/response service. It runs DBOS.launch() and pg-boss and stays
resident, so it can never live on a scale-to-zero platform.
What runs where
| Service | Command | Published? | Notes |
|---|---|---|---|
postgres | built from infra/postgres | no | System of record for every tenant |
api | apps/api/src/index.ts | via Caddy only | The only bridge between the two networks |
web | apps/web (TanStack Start SSR) | via Caddy only | Admin UI |
site | apps/site (Next.js) | via Caddy only | Landing + credential page |
caddy | reverse proxy | 80, 443 | The only container with published ports |
agents | apps/agents/src/index.ts | no, not routed | Internal only — x-internal-token required |
credentials | apps/credentials/src/index.ts | no, not routed | Internal only — x-internal-token required |
workflow | apps/workflow/src/index.ts | no | Long-running worker, no HTTP surface |
migrate, seed, bootstrap-admin | one-shot | no | Gated behind the tools profile |
Everything is one image (connected-campus-app:local), built once and run six times with a
different command. apps/web depends on @campus/api via workspace:*, and @campus/db /
@campus/observability are shared by everything, so six independently pruned images would mean six
dependency graphs to keep coherent. One image cannot drift out of sync with itself.
Routing, all on one hostname plus two derived subdomains:
https://<DOMAIN>/api-proxy/* -> api:4000 (prefix stripped: /api-proxy/people -> /people)
https://<DOMAIN>/* -> web:3000
https://docs.<DOMAIN>/* -> static Docusaurus output, served by Caddy
https://site.<DOMAIN>/* -> site:3002
The API is proxied under a same-origin path rather than a subdomain deliberately. Better Auth's
session cookie is issued by the API, so on api.<DOMAIN> it would be a third-party cookie and
SameSite=Lax (the default) would stop it being sent on the app's fetches. Same-origin keeps cookie
semantics identical to local development, with no auth code changes.
Sizing
| Resource | Value |
|---|---|
| Disk | 40 GB+. Measured on a local build: the app image is 4.26 GB on disk (it carries the whole installed workspace — ~2.9 GB of node_modules — plus both built front-ends) and the Postgres image is 1.94 GB, because it compiles both extensions from source. Add the database volume, which starts small and grows with imported data. Note that docker image inspect --format '{{.Size}}' reports ~0.8 GB for the app image, which is wrong on this Docker version — trust docker image ls / docker system df |
| RAM | 8 GB comfortable, 16 GB generous. Postgres 2–4 GB, six small Bun services well under 1 GB combined, OS + Docker ~1 GB. The tight moment is CPU during the Postgres image build, not memory at runtime |
| CPU | 2 vCPU minimum, 4 better — only really matters while building the Postgres image |
Before you start
- A DNS A record for
DOMAIN, plusdocs.DOMAINandsite.DOMAIN, pointing at the VPS. Caddy provisions Let's Encrypt certificates from the SNI hostname, so this must resolve before the firstupor certificate issuance fails. - Ports 80 and 443 open. Caddy needs 80 for the ACME challenge.
- Docker Engine with Compose v2.
Deploy
git clone <repo> && cd ConnectedCampus
cp .env.production.example .env
chmod 600 .env
# Fill in: DOMAIN, PUBLIC_URL, POSTGRES_PASSWORD, RUNTIME_DB_PASSWORD,
# BETTER_AUTH_SECRET, INTERNAL_SERVICE_TOKEN, ADMIN_PASSWORD
openssl rand -hex 32 # for each of the two secrets and both passwords
docker-compose.prod.yml refuses to start if any required value is missing — the error names the
variable, so a half-filled .env fails immediately rather than booting with campus/campus.
# 1. Build. The Postgres stage compiles pgvector + AGE from source: several minutes, and the
# heaviest step. Rebuilds after a source change reuse the Bun cache and are much faster.
docker compose -f docker-compose.prod.yml build
# 2. Bring up the database and wait for it to accept connections.
docker compose -f docker-compose.prod.yml up -d postgres
docker compose -f docker-compose.prod.yml ps postgres # wait for (healthy)
# 3. Migrate. Must run as the OWNING role (DATABASE_URL) — this is what creates the
# campus_app NOBYPASSRLS role and applies the RLS policies. Run it on every deploy.
docker compose -f docker-compose.prod.yml --profile tools run --rm migrate
# 4. Seed the platform-level module catalogue (M1–M20 in module_definitions).
# Needed for a from-scratch institution; skip it for the legacy demo tenant.
# No tenant data: reference lookups come from the import, org/people from the UI.
docker compose -f docker-compose.prod.yml --profile tools run --rm seed
# 5. Create the first admin. Prints its own confirmation; safe to re-run.
docker compose -f docker-compose.prod.yml --profile tools run --rm bootstrap-admin
# 6. Everything else.
docker compose -f docker-compose.prod.yml up -d
Verify
docker compose -f docker-compose.prod.yml ps # every service healthy
curl -s https://$DOMAIN/api-proxy/health # {"status":"ok","service":"api",...}
curl -s -o /dev/null -w '%{http_code}\n' https://$DOMAIN/login # 200
curl -s -o /dev/null -w '%{http_code}\n' https://$DOMAIN/admin # 307 (redirect to /login)
That 307 is the single most informative check. /admin runs a server-side session lookup, so it
exercises the API from inside the web container — if INTERNAL_API_URL is wrong you get a 500
instead (see Troubleshooting).
Then sign in at https://$DOMAIN/login with ADMIN_EMAIL / ADMIN_PASSWORD.
Updating
git pull
docker compose -f docker-compose.prod.yml build
docker compose -f docker-compose.prod.yml --profile tools run --rm migrate
docker compose -f docker-compose.prod.yml up -d
Changing DOMAIN or PUBLIC_URL requires rebuilding the app image, not just restarting:
VITE_API_URL is inlined into the browser bundle by Vite at build time. It is not a runtime
setting, so a running image keeps calling the URL it was built with.
Operating it
# Logs
docker compose -f docker-compose.prod.yml logs -f api web
# psql (Postgres publishes no port, so go through the container)
docker compose -f docker-compose.prod.yml exec postgres psql -U campus -d campus_os
# Backup
docker compose -f docker-compose.prod.yml exec -T postgres \
pg_dump -U campus campus_os | gzip > campus_os-$(date +%F).sql.gz
A rebuild restarts services; all state lives in the campus_postgres_data volume. pg_dump above
is a manual step — no scheduled backup exists yet.
Troubleshooting
| Symptom | Cause |
|---|---|
/admin returns 500, /login returns 200 | INTERNAL_API_URL is unset or wrong. Server-rendered session checks fall back to the public URL (inlined into the server bundle by Vite), which is not reachable from inside the container. Should be http://api:4000 |
| Login fails with an origin/CORS error | BETTER_AUTH_URL and WEB_APP_URL must both match the public URLs. BETTER_AUTH_URL is the API's own URL (https://<DOMAIN>/api-proxy); WEB_APP_URL is the app's (https://<DOMAIN>) |
| Caddy logs a certificate/ACME error | DNS does not resolve to this host yet, or port 80 is blocked |
502 from Caddy, service is up | Service name/port mismatch — Caddy reaches api:4000, web:3000, site:3002 over the edge network |
| Migrations "succeed" but RLS does nothing | migrate ran with the wrong role. It needs DATABASE_URL (owner); services run on RUNTIME_DATABASE_URL (campus_app, NOBYPASSRLS) |
| The browser calls the wrong API URL | The image was built with a different PUBLIC_URL/VITE_API_URL. Rebuild, do not restart |
What has and hasn't been verified
Verified by building the image and running the containers:
- The app image builds (
--target app) and contains the builtapps/webandapps/siteoutput; workspace links resolve inside it (@campus/db→/app/packages/db/src/index.ts). api:/healthreturns 200, the compose healthcheck command exits 0, and/modulesreturns 401 unauthenticated.web:/and/loginreturn 200;/adminand/admin/peoplereturn 307 → /login withINTERNAL_API_URLset, and 500 without it (that difference is why the variable exists).site:/returns 200 undernext start.agents:/healthis exempt from the token gate; unauthenticated non-health requests fail closed with 503.docker compose configvalidates, and fails with a named-variable error when a required secret is missing.VITE_API_URLreally is inlined into the browser bundle at build time.
Not verified — the first deploy should be treated as the test:
- Caddy, TLS, and the three hostnames. The
caddyimage target has not been built here. - The full stack running together via
docker compose up. - A complete browser sign-in through the proxy. The 307 proves the SSR session call reaches the API; the cookie round-trip through Caddy has not been exercised end to end.
migrate,bootstrap-admin, and the legacy import running inside containers rather than on a workstation.- Volume persistence across a restart.
Before real data goes in
The network posture is deliberate: only Caddy is reachable, Postgres publishes no port, and the two internal services are on a network Caddy has no route to. The remaining gaps are application-level, and they matter more once real people are using it:
- Read routes have no
requirePermission. Tenant isolation is enforced by RLS and is solid; intra-tenant role scoping is not applied, so any authenticated member of a tenant can list all people, org structure, and attendance. See API Surface. workflowconnects with the owning role, so DBOS and pg-boss state sits outside the RLS boundary (they need DDL, which the runtime role deliberately lacks). See Workflow.- Nothing calls
agentsorcredentialsyet — they are hardened and running, but unreached. - No scheduled backups, no monitoring, no log aggregation. Single node: no failover.
- The legacy ERP import is a manual, one-time run (
packages/db/src/import/), not part of the deploy.