Skip to main content

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​

ServiceCommandPublished?Notes
postgresbuilt from infra/postgresnoSystem of record for every tenant
apiapps/api/src/index.tsvia Caddy onlyThe only bridge between the two networks
webapps/web (TanStack Start SSR)via Caddy onlyAdmin UI
siteapps/site (Next.js)via Caddy onlyLanding + credential page
caddyreverse proxy80, 443The only container with published ports
agentsapps/agents/src/index.tsno, not routedInternal only — x-internal-token required
credentialsapps/credentials/src/index.tsno, not routedInternal only — x-internal-token required
workflowapps/workflow/src/index.tsnoLong-running worker, no HTTP surface
migrate, seed, bootstrap-adminone-shotnoGated 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​

ResourceValue
Disk40 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
RAM8 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
CPU2 vCPU minimum, 4 better — only really matters while building the Postgres image

Before you start​

  1. A DNS A record for DOMAIN, plus docs.DOMAIN and site.DOMAIN, pointing at the VPS. Caddy provisions Let's Encrypt certificates from the SNI hostname, so this must resolve before the first up or certificate issuance fails.
  2. Ports 80 and 443 open. Caddy needs 80 for the ACME challenge.
  3. 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​

SymptomCause
/admin returns 500, /login returns 200INTERNAL_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 errorBETTER_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 errorDNS does not resolve to this host yet, or port 80 is blocked
502 from Caddy, service is upService name/port mismatch — Caddy reaches api:4000, web:3000, site:3002 over the edge network
Migrations "succeed" but RLS does nothingmigrate 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 URLThe 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 built apps/web and apps/site output; workspace links resolve inside it (@campus/db → /app/packages/db/src/index.ts).
  • api: /health returns 200, the compose healthcheck command exits 0, and /modules returns 401 unauthenticated.
  • web: / and /login return 200; /admin and /admin/people return 307 → /login with INTERNAL_API_URL set, and 500 without it (that difference is why the variable exists).
  • site: / returns 200 under next start.
  • agents: /health is exempt from the token gate; unauthenticated non-health requests fail closed with 503.
  • docker compose config validates, and fails with a named-variable error when a required secret is missing.
  • VITE_API_URL really 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 caddy image 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.
  • workflow connects 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 agents or credentials yet — 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.