Skip to main content

Getting started

Two paths: bring up an empty system (for a from-scratch institution), or import the real CampusStack demo tenant (the data this build was grounded against). Both start the same way.

Prerequisites​

  • Bun 1.4+ (the repo pins bun@1.4.2 in the root package.json)
  • Docker — runs Postgres, and temporarily MySQL if you are importing
  • A Sentry DSN — optional. Every service no-ops cleanly with a warning if SENTRY_DSN is unset.

1. Install and configure​

bun install
cp .env.example .env

The DATABASE_URL default in .env.example already matches docker-compose.yml. Set BETTER_AUTH_SECRET to any random string — auth will not work without it.

Two database URLs are used, and the distinction is load-bearing:

VariableRoleUsed by
DATABASE_URLcampus — the table owner (a superuser in the official Postgres image)migrate, generate, seed, and the import scripts only
RUNTIME_DATABASE_URLcampus_app — a non-superuser, NOBYPASSRLS roleevery running service, via packages/db/src/client.ts

This is not tidiness. Postgres superusers bypass RLS unconditionally — there is no policy setting that can restrict one — so an app connecting as campus would silently have no tenant isolation at all. That was a real bug in this build, caught by rls.test.ts; see Multi-Tenancy for the full story. migrate.ts creates the campus_app role idempotently, so you never create it by hand.

One more variable worth setting: INTERNAL_SERVICE_TOKEN. apps/agents and apps/credentials sit outside Better Auth and authenticate with this shared secret (sent as the x-internal-token header). If it is unset, both services fail closed — 503 on every request except /health — rather than serving unauthenticated traffic, so they are safe by default but useless without it. openssl rand -hex 32 is fine for local dev. See Agents.

2. Start Postgres​

docker compose up -d

This builds the Postgres image from infra/postgres/Dockerfile — no prebuilt image ships both pgvector and Apache AGE together. First run takes a few minutes. Extensions are applied automatically on container init via infra/postgres/init-extensions.sql, and AGE is loaded as a shared_preload_libraries entry (passed explicitly in docker-compose.yml rather than baked into the image, so it stays visible and overridable).

The host port is 55432, not 5432 — a system-installed Postgres commonly occupies 5432.

Running Postgres natively instead (no Docker / low-RAM WSL2 machines)​

On a memory-constrained machine — an 8GB WSL2 laptop, for example — Docker itself is usually the actual cost, not this repo. Measured on a reference machine: the Postgres container idles at ~113MB; a single Bun service like apps/api idles at ~90-120MB. Postgres was never the expensive part. Docker Desktop's WSL2 VM commonly reserves up to 50% of host RAM by default (4GB on an 8GB machine) regardless of what's actually running inside it — that reservation, not the containers themselves, is almost always what's causing the pressure.

./infra/postgres-native/setup.sh installs Postgres 18 + pgvector + Apache AGE directly on a Debian/Ubuntu host (WSL2's default distro), mirroring infra/postgres/Dockerfile's steps exactly, just run on the host instead of in a container. It configures Postgres to listen on port 55432, so it's a true drop-in for the Docker setup — .env needs zero changes. Run it once, then continue with step 3 below (bun run db:migrate) exactly as normal.

Once it's working, uninstall or stop Docker Desktop — that's the step that actually frees the multi-GB reservation. If you're keeping WSL2 for other work, cap its total memory so it can't balloon again: create (or edit) %UserProfile%\.wslconfig on the Windows side (not inside WSL2) with:

[wsl2]
memory=6GB
processors=4

then run wsl --shutdown from PowerShell and reopen your WSL2 terminal for it to take effect.

If you only need apps/api (not the full 7-service dev stack), that's the lightest possible setup — Postgres + one Bun process, ~250MB total once Docker Desktop is gone:

bun --filter '@campus/api' dev # instead of `bun run dev`
curl http://localhost:4000/health # fastest smoke test

Run bun run db:migrate first (creates the schema, RLS policies, and the campus_app runtime role) — the API won't serve real requests until that's done. To run just the tests that matter at this scope instead of all 11 packages: turbo run test --filter=@campus/db --filter=@campus/api.

3. Migrate and seed​

bun run db:generate # only if you changed the Drizzle schema
bun run db:migrate # applies migrations, then RLS policies, then the campus_app role
bun run db:seed # seeds the 20 KIOT module_definitions rows (platform data)

db:migrate is not just drizzle-kit migrate. It runs, in order:

  1. Drizzle migrations from packages/db/migrations/
  2. RLS_SETUP_SQL — ENABLE ROW LEVEL SECURITY + CREATE POLICY per table (guarded per-table, so adding a table to TENANT_SCOPED_TABLES later gets its policy on the next run)
  3. RLS_FORCE_SQL — FORCE ROW LEVEL SECURITY, which is required because ENABLE alone does not apply to a table's own owner
  4. RLS_POLICY_HARDEN_SQL — ALTER POLICY, always safe to re-run
  5. appRoleSetupSql() — creates the campus_app role and its grants

Note that reference lookup tables (nationality, religion, community, caste, mother tongue, blood group) are not seeded. They come from the import pipeline, so that there is exactly one parser for the legacy data rather than a second hand-written one for the small tables.

4. Run it​

bun run dev

Turborepo runs every app's dev script in parallel. apps/web is then reachable at http://localhost:3000 — but with no tenant, no users, and no way to log in. Do either the import below, or create a tenant/organization/admin user by hand.

Individual services:

bun --filter '@campus/api' dev # http://localhost:4000
bun --filter '@campus/web' dev # http://localhost:3000
bun --filter '@campus/docs' dev # http://localhost:3003

.env loading caveat. Bun only auto-loads a .env from the current working directory, not from parent directories. bun run dev works because Turborepo loads the root .env for each task it runs. Scripts invoked directly from inside a package directory do not get it for free — pass --env-file=../../.env, which is what the import:* scripts below do.

Importing the real institution data (optional)​

Reproduces the demo tenant: real org structure, students, staff, enrollments, curriculum, and millions of real per-class attendance records from a legacy college ERP backup (docs/cs-04Aug2026-02 (1)/). Skip this if you are starting a fresh institution.

The import scripts read from a real MySQL instance rather than parsing the dump text — loading mysqldump INSERT syntax by hand is far less reliable.

1. Load the legacy backup into a throwaway MySQL container:

docker compose -f infra/import/docker-compose.mysql.yml up -d

until [ "$(docker inspect --format='{{.State.Health.Status}}' import-mysql-import-1)" = "healthy" ]; do sleep 3; done

./infra/import/load-legacy-tables.sh "docs/cs-04Aug2026-02 (1)/home/campusstack/backupwp"

The loader pulls only the tables the importers need and loads them flat, dropping the schema prefix.

2. Run the import pipeline, in order. Each script is independently idempotent — safe to re-run, and each does a full clean-slate reimport for this tenant rather than diffing against a partial run.

cd packages/db
bun --env-file=../../.env run import:legacy # reference data, org structure, students, staff, enrollments
bun --env-file=../../.env run import:curriculum # subjects, class sections, faculty assignments, per-class attendance
bun --env-file=../../.env run import:staff-attendance # staff biometric FN/AN presence records
cd ../..

import:curriculum streams millions of rows and takes several minutes.

3. Tear down the throwaway MySQL container — it is not part of the permanent stack:

docker compose -f infra/import/docker-compose.mysql.yml down -v

4. Bootstrap an admin login for the imported tenant (creates a Better Auth organization plus an admin user; one-time):

cd apps/api
bun --env-file=../../.env run bootstrap-admin
cd ../..

This prints the login. Defaults are admin@campusstack.local / ChangeMe123!, overridable with ADMIN_EMAIL / ADMIN_PASSWORD. Then start bun run dev and log in at http://localhost:3000/login.

Verifying a change​

bun run typecheck # turbo run typecheck, across all packages
bun run test # bun test — needs Postgres up, since rls.test.ts hits the real DB

bun run test will fail without a running Postgres: the RLS test is an integration test against the real database by design, because RLS is enforced by Postgres, not by application code. If you are touching anything in packages/db, that test is the one to watch.