6.6 KiB
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:
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 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:
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: rawSERVICE_RUNTIME_PASSWORD=<generated>for PostgreSQL role bootstrap and compatibility validation..env.staging:SERVICE_DATABASE_URL=postgresql://service_runtime:<percent-encoded-service-runtime-password>@postgres:5432/jyotishafor 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:
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:
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:
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:
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.