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.2in the rootpackage.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_DSNis 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:
| Variable | Role | Used by |
|---|---|---|
DATABASE_URL | campus — the table owner (a superuser in the official Postgres image) | migrate, generate, seed, and the import scripts only |
RUNTIME_DATABASE_URL | campus_app — a non-superuser, NOBYPASSRLS role | every 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:
- Drizzle migrations from
packages/db/migrations/ RLS_SETUP_SQL—ENABLE ROW LEVEL SECURITY+CREATE POLICYper table (guarded per-table, so adding a table toTENANT_SCOPED_TABLESlater gets its policy on the next run)RLS_FORCE_SQL—FORCE ROW LEVEL SECURITY, which is required becauseENABLEalone does not apply to a table's own ownerRLS_POLICY_HARDEN_SQL—ALTER POLICY, always safe to re-runappRoleSetupSql()— creates thecampus_approle 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
.envloading caveat. Bun only auto-loads a.envfrom the current working directory, not from parent directories.bun run devworks because Turborepo loads the root.envfor 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 theimport:*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.