# Self-hosted identity operations Staging uses Better Auth and the private local PostgreSQL cluster for both identity and business data. Production remains on Supabase; this runbook does not authorize a production cutover. ## Staging mode Keep these values exactly as shown: ```dotenv AUTH_PROVIDER=self-hosted SELF_HOSTED_IDENTITY_ENABLED=true AUTH_USER_ORIGIN=https://staging.jyotisha.chat ADMIN_USER_ORIGIN=https://admin.staging.jyotisha.chat ``` The two exact hosts run the same Next.js application and Better Auth service. They share one `BETTER_AUTH_USER_SECRET` and cookie prefix, but Better Auth cookies do not set a `Domain` attribute, so the browser keeps main-site and admin-site sessions host-only. The admin host root redirects to `/admin`, and its login page returns to `/admin` after OTP, password, or MFA completion. The main host login continues to return to `/`. The main auth surface still returns `404` for Better Auth `/api/auth/admin` plugin endpoints; the exact admin host may dispatch those endpoints to the same service. Unknown, suffix-spoofed, malformed, or unconfigured hosts fail closed with `421`. Server routes translate the authenticated user session into PostgreSQL request claims. Admin authorization reads the persisted `identity.users.role` value and permits only `admin`; `viewer` and ordinary users receive `403` regardless of which host receives the request. Use [the tracked staging identity example](../../deploy/.env.staging.identity.example) as a list of names only. Replace bracketed values directly on the server. Keep both `/opt/jyotisha-staging/.env.staging` and `/opt/jyotisha-staging/.env.staging.database` on the staging host, owned by the deployment user, and at mode `0600`. Generate `BETTER_AUTH_USER_SECRET` locally on the server: ```bash openssl rand -base64 32 ``` Do not reuse it as a PostgreSQL password. `IDENTITY_DATABASE_URL`, `APP_DATABASE_URL`, `SERVICE_DATABASE_URL`, and `ADMIN_DATABASE_URL` use their matching passwords from `.env.staging.database`, percent-encoded only in each URL password component. All four must point to the private Compose hostname `postgres:5432/jyotisha`; never publish PostgreSQL on a host port. For the service identity, generate one independent secret and store it in exactly these two host-managed forms: - `.env.staging.database`: raw `SERVICE_RUNTIME_PASSWORD=` for PostgreSQL role bootstrap and compatibility validation. - `.env.staging`: `SERVICE_DATABASE_URL=postgresql://service_runtime:@postgres:5432/jyotisha` for server runtime access. The staging deploy controller disables shell tracing and runs both environment validators before any Compose config, pull, or up operation. Neither staging workflow stores, interpolates, passes, or prints `SERVICE_RUNTIME_PASSWORD` or `SERVICE_DATABASE_URL`; they remain in the mode-`0600` host files. The Resend key must be staging-only. `RESEND_FROM_EMAIL` must use a sender/domain verified in Resend. CI never receives this key and uses an in-memory sender. `ADMIN_EMAILS` remains relevant only to the legacy Supabase production path; it is not self-hosted admin authorization. Validate without printing values: ```bash cd /opt/jyotisha-staging chmod 600 .env.staging .env.staging.database bash deploy/validate-staging-env.sh \ .env.staging staging.jyotisha.chat deploy/Caddyfile.staging bash deploy/validate-staging-database-env.sh .env.staging.database ``` ## Migration and smoke checks Apply the reviewed PostgreSQL migrations through the existing `Migrate Staging Database` workflow before deploying the web image. Better Auth users are transactionally projected into `auth.users`, which creates their business profile through the existing trigger. After deployment, verify both exact hosts without sending credentials: ```bash curl -fsS https://staging.jyotisha.chat/login >/dev/null test "$(curl -sS -o /dev/null -w '%{http_code}' https://staging.jyotisha.chat/api/account)" = 401 test "$(curl -sS -o /dev/null -w '%{http_code}' https://admin.staging.jyotisha.chat/)" = 308 test "$(curl -sSI https://admin.staging.jyotisha.chat/ | tr -d '\r' | awk 'tolower($1) == "location:" { print $2 }')" = /admin curl -fsS https://admin.staging.jyotisha.chat/login >/dev/null test "$(curl -sS -o /dev/null -w '%{http_code}' https://admin.staging.jyotisha.chat/api/admin/session)" = 401 ``` An anonymous admin-host `/admin` request redirects to the admin-host `/login`. An authenticated non-admin, including a persisted `viewer`, must not render the admin layout and every `/api/admin/*` route must independently return `403`. Promote a staging user only through a reviewed database operation; the persisted role must include `admin` before the admin-host session can enter the backend. Complete MFA enrollment, MFA login, and privileged reauthentication on `admin.staging.jyotisha.chat` so every resulting challenge, session, and reauth cookie remains on the admin host. ## Optional import rehearsal Export Supabase Auth users to a JSON array in the supported fixture shape, then run a redacted dry-run first: ```bash cd /opt/jyotisha-staging/frontend node scripts/import-supabase-auth-users.mjs /secure/path/auth-users.json ``` The summary contains only counts. It preserves UUID, normalized email, verification timestamps, display metadata, and created/updated timestamps. It intentionally ignores passwords, sessions, JWTs, provider secrets, and Supabase platform fields. Apply only after reviewing the dry-run and taking a local encrypted staging backup: ```bash set -a . ../.env.staging set +a node scripts/import-supabase-auth-users.mjs /secure/path/auth-users.json --apply unset IDENTITY_DATABASE_URL ``` Reruns are idempotent by UUID and the whole import is transactional. Duplicate canonical emails abort before database writes. ## Rollback and rotation An application rollback must use a previously validated staging image and does not reverse database migrations. Existing self-hosted sessions and data remain in PostgreSQL; do not delete identity or business rows during application rollback. Returning staging to Supabase requires a separate reviewed data-reconciliation and provider-switch change. Rotating `BETTER_AUTH_USER_SECRET` invalidates all self-hosted browser sessions, including admins. Restart the web service and verify login, anonymous admin API rejection, admin access, and viewer rejection. Rotate a leaked Resend key in Resend first, replace the server value, then restart. Never print old or new values. Production `AUTH_PROVIDER=self-hosted` remains blocked until data reconciliation passes, production backups and restore drills exist, operational monitoring is ready, and a separate reviewed production cutover plan is approved.