Skip to main content

Local Development

Run the Chilarai stack locally: infra in Docker, apps via Nx.

Prerequisites

  • Node.js 22 LTS+
  • pnpm 10+
  • Docker Desktop

1. Install

cd chilarai
pnpm install

2. Start infrastructure

Postgres, Redis, Keycloak, MinIO, plus the telemetry pipeline (OTel Collector + Grafana/LGTM) run in Docker.

cp infra/env/local.env.example infra/env/local.env # fill in passwords
pnpm infra:up # start | infra:down, infra:logs, infra:ps
ServiceURL
Postgreslocalhost:5432
Keycloakhttp://localhost:8080 (realm chilarai)
MinIO consolehttp://localhost:9001
OTel CollectorOTLP localhost:4317 (gRPC) / 4318 (HTTP)
Grafana (LGTM)http://localhost:4000

3. Configure the API env

Nx auto-loads api/.env when serving. It is gitignored; copy the template:

cp api/.env.example api/.env

Defaults point at local infra (DATABASE_URL, AUTH_PROVIDER=keycloak, KEYCLOAK_*). The API listens on port 3333 (PORT) to stay clear of the web app on 3000.

Then the web app env (also gitignored):

cp web/.env.local.example web/.env.local # then set AUTH_SECRET (npx auth secret)

The BFF proxy uses API_BASE_URL (http://localhost:3333/api) to reach the API server-side.

4. Database

pnpm db:migrate # apply migrations
pnpm db:seed # roles, permissions, scopes (idempotent)

Drizzle helpers: db:generate (new migration), db:push, db:studio.

5. Run the apps

Start both together:

pnpm dev # API (:3333) + web (:3000) in parallel

Or run them individually:

pnpm api:serve # API -> http://localhost:3333/api
pnpm web:dev # Web -> http://localhost:3000

Quick check (no auth): GET http://localhost:3333/api{"message":"Hello API"}.

Bootstrap the first platform administrator

Provision admin@ujanlabs.com in the chilarai Keycloak realm first, then copy that user's immutable Keycloak ID (sub). Bootstrap application access with:

$env:DATABASE_URL="postgres://<user>:<password>@localhost:5432/chilarai"
$env:BOOTSTRAP_ADMIN_SUB="<keycloak-user-id>"
$env:BOOTSTRAP_ADMIN_EMAIL="admin@ujanlabs.com"
pnpm db:bootstrap:platform-admin

BOOTSTRAP_ADMIN_FIRST_NAME and BOOTSTRAP_ADMIN_LAST_NAME optionally override the default display name. The command is idempotent for the same Keycloak user and does not create a school membership. Use platform-user management for additional platform administrators.

6. Authenticate and test /me

/me requires a Keycloak bearer token, and the caller must be provisioned in the DB. Use the platform administrator bootstrapped above.

  1. Get a token from the chilarai realm and decode its sub:

    function Decode-Jwt($t){ $p=$t.Split('.')[1].Replace('-','+').Replace('_','/'); switch($p.Length%4){2{$p+='=='}3{$p+='='}}; [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($p)) | ConvertFrom-Json | Select-Object -ExpandProperty sub }
    Decode-Jwt "PASTE_TOKEN"
  2. Call /me:

    $h = @{ Authorization = "Bearer <token>" }
    Invoke-RestMethod http://localhost:3333/api/me -Headers $h | ConvertTo-Json -Depth 6

    Before any tenants are provisioned, the platform administrator has a platform role but no school memberships. Tenant-scoped calls become available after a tenant, school, and membership are created through platform administration.

7. Sign in via the web app

The web app uses NextAuth (Auth.js) with Keycloak and a BFF proxy — the browser never holds the token.

  1. Ensure the API (3333) and web (3000) are running and web/.env.local is set.
  2. Open http://localhost:3000Sign in with Keycloak → log in.
  3. The home page shows you signed in. Data screens call the API through the BFF (/bff/*), which attaches the bearer token server-side.

The browser only ever calls the Next.js origin (/bff/*); Next forwards to the API with the bearer token attached server-side.

8. Telemetry (OpenTelemetry → Grafana)

Both apps are instrumented with OpenTelemetry and export OTLP to the local Collector, which redacts secrets and forwards to the Grafana/LGTM stack.

  1. pnpm infra:up starts the Collector and Grafana (see the infra table).
  2. Run the apps (pnpm dev) and generate some traffic (open the app, sign in).
  3. Open Grafana at http://localhost:4000Explore:
    • Tempo — traces (a request spans chilarai-webchilarai-api → Postgres)
    • Loki — logs (correlated to traces by trace_id)
    • Mimir/Prometheus — metrics

Config: apps read OTEL_* env (endpoint http://localhost:4318); the Collector config is infra/otel/collector-config.yaml. Only the Collector's exporter changes between environments — the app instrumentation stays the same.

Scripts reference

CommandPurpose
pnpm infra:up / :down / :logs / :psManage Docker infra
pnpm db:migrate / db:seedMigrate schema / seed authorization records
pnpm db:bootstrap:platform-adminGrant the first Keycloak user platform-admin access
pnpm db:generate / db:push / db:studioDrizzle tooling
pnpm api:serve / api:build / api:test / api:lintAPI tasks
pnpm web:dev / web:build / web:test / web:lintWeb tasks
pnpm devRun API + web together

Troubleshooting

SymptomCause / Fix
401 Unauthorized on /meMissing/expired/invalid token. Send Authorization: Bearer <token>; check the API log for the JwtAuthGuard reason.
jwt issuer invalid. expected: …/realms/chilaraiToken minted from the wrong realm/host. Get it from the chilarai realm on localhost:8080, or set KEYCLOAK_ISSUER_URL to match the token's iss.
/me returns user: nullToken is valid but the identity is not provisioned. Bootstrap the first platform admin or create the user through platform administration.
400 X-Tenant-Code header is requiredA tenant-scoped endpoint was called without the header.
403 No active membership for tenant "…"Caller has no active membership in that tenant. Verify the user's school membership in platform administration.
Keycloak login Invalid redirect_uriOpen the web app on http://localhost:3000; the chilarai-web client allows http://localhost:3000/*.
401 from /bff/* in the browserNot signed in or the session expired — sign in again.
DB connection errorsEnsure pnpm infra:up is running and DATABASE_URL matches infra/env/local.env.