16 KiB
Production migration to 118.194.235.34
Status: planned; do not change DNS or retire the old production yet.
This runbook moves production to the current reviewed staging release while also changing the persistence and identity layers:
- old runtime: VPS + Supabase PostgreSQL + Supabase Auth;
- target runtime:
118.194.235.34+ private PostgreSQL 17 + Better Auth; - user site:
https://jyotisha.chat; - admin site:
https://admin.jyotisha.chat.
This is not a volume copy. A full Supabase dump must not be restored over the target database.
Confirmed migration decisions
- production Owner:
luna@copse.life/b8907d0c-6ed0-4270-b866-7e83bb4a1b26; - source Supabase project:
vtvnfqmonbfuxmqkqdlc; - the legacy production
ADMIN_EMAILSallowlist contains only the designated Owner, and the legacy database has nopublic.admin_users; the ETL seeds that attested Owner into the target admin model; - payment and model-provider ciphertext mode:
exclude; re-enter both configurations after cutover with newly generated encryption keys; - release policy: validate on
staging, then promote the same accepted SHA to production; - DNS remains unchanged until the final ETL and reconciliation pass;
- proposed maintenance window:
2026-08-12 02:00–04:00 UTC+8, pending operator confirmation after rehearsal timing.
Release invariants
The production deployment and schema-migration workflows are manual-only and accept a full lowercase 40-character deploy_sha. A normal production mutation proceeds only when all of the following identify that exact SHA:
- current
main; - current
staging; - a successful push-triggered
Staging Backend Quality Gate; - a successful manually triggered
Jyotish Release Quality Gate; - the public staging
/api/healthdeployment identity.
The deployment workflow consumes the exact API and Web image digests recorded by the staging gate. It does not build on the 2-core/4-GB production host, import user data, run schema migrations, or change DNS. The separate Migrate Production Database workflow uses the same gate-attested Web image only to run the schema checker/migrator/checker sequence; it does not run ETL, deploy the application, or change DNS. Both workflows share the production-mutation lock.
Required Gitea configuration
Repository variables:
| Name | Required value |
|---|---|
PRODUCTION_HOST |
118.194.235.34 |
PRODUCTION_PORT |
22 (confirmed on 2026-08-09) |
PRODUCTION_USER |
deploy |
PRODUCTION_PATH |
/opt/jyotisha-production |
PRODUCTION_URL |
https://jyotisha.chat |
PRODUCTION_ADMIN_URL |
https://admin.jyotisha.chat |
PRODUCTION_KNOWN_HOSTS |
Independently verified pinned host-key line |
STAGING_URL |
https://staging.jyotisha.chat |
Repository secrets:
PRODUCTION_SSH_PRIVATE_KEY: the dedicated deploy private-key file encoded as one unwrapped base64 line;REGISTRY_USERNAMEandREGISTRY_PASSWORD.
Do not put the Ubuntu password, database URLs, Resend key, payment key, model-provider key, or encryption master keys in Gitea. The supplied bootstrap password must be rotated after an SSH key has been verified; it must never be committed or printed in a workflow.
New-server bootstrap
Perform this interactively before any workflow dispatch:
- Patch Ubuntu and install Docker Engine, Compose v2,
rsync,curl,jq,flock, and UFW. - Create a non-root
deployuser, install a dedicated Ed25519 public key, and grant only the reviewed passwordless commands needed for Docker and deployment-tree ownership. - Verify a second key-only session and rotate the exposed bootstrap password. For this host, the production owner explicitly requires password authentication to remain enabled for other operators; do not change
PasswordAuthentication. Workflows must still use the dedicated deploy key. - Permit only the confirmed SSH port plus
80/tcp,443/tcp, and443/udp. Do not publish3000,5200,5432, or the Docker API. - Create a 2–4 GB swap file and enable Docker log rotation. Keep at least 15 GB free before the first image pull and database import.
- Create
/opt/jyotisha-production, owned bydeploy, and preload the reviewedpostgres:17-alpineand Caddy images. PostgreSQL image upgrades are separate maintenance operations.
Create these host-only files with owner deploy and mode 0600:
/opt/jyotisha-production/.env.production
/opt/jyotisha-production/.env.production.database
The application selectors must be exact:
APP_ENV_FILE=../.env.production
CADDYFILE_PATH=./Caddyfile.production.selfhosted
SITE_ADDRESS=https://jyotisha.chat
AUTH_PROVIDER=self-hosted
SELF_HOSTED_IDENTITY_ENABLED=true
AUTH_USER_ORIGIN=https://jyotisha.chat
ADMIN_USER_ORIGIN=https://admin.jyotisha.chat
EPAY_CHAT_ENABLED=false
Use distinct production credentials for PostgreSQL roles, Better Auth, Resend, backup encryption, and dynamic rectification. Preserve the existing EPAY_CONFIG_ENCRYPTION_KEY and MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY only through an approved secret-to-secret transfer. If either key cannot be transferred safely, exclude its ciphertext rows from import and re-enter those settings in the new admin UI.
Database migration engineering gate
Before importing data, dispatch Gitea Actions → Migrate Production Database for the exact accepted release SHA. The workflow requires main == staging == deploy_sha, the same successful staging backend gate, the same manual release gate, and the public staging /api/health identity for that SHA. It also requires a non-sensitive recovery reference, its exact UTC creation time, and restore_verified=true; the recovery point must be no more than 24 hours old and must already have passed a restore verification. It verifies the current production revision, obtains the gate-attested immutable Web image, runs the schema checker, applies only pending application schema migrations, and requires the checker to converge afterward.
Schema migration files are committed sequentially and are not one atomic transaction as a set. If a later file or post-check fails, earlier files may remain applied; stop, preserve evidence, and restore from the attested recovery point when repair-in-place is not explicitly reviewed. Do not assume a failed workflow means the database is unchanged.
The production database must already grant migration_runner membership in schema_owner; deploy/postgres/001-bootstrap-roles.sh grants only that migration role the ability to SET ROLE schema_owner. Identity, app, service, admin, and backup runtime roles must not receive this membership. The workflow checks the membership and refuses to add it itself.
The reviewed data-transfer entry point is frontend/scripts/migrate-supabase-production.mjs. It has separate --preflight, --apply, and --verify modes; the application deployment workflow never runs it automatically. Production cutover remains blocked until the exact production snapshot has completed an isolated rehearsal and final verification.
The tool must:
- connect to the source using
REPEATABLE READ READ ONLY; - refuse a non-empty target business database;
- use explicit columns and dependency order, never
SELECT *; - preserve all user UUIDs and transactional primary keys;
- import Supabase
auth.usersinto Better Auth without passwords, sessions, JWTs, provider tokens, or MFA secrets; - map
banned_until, or emit an explicit blocked-user reconciliation manifest; - merge seed/configuration records by natural key rather than copying target-generated IDs;
- map active administrators to canonical target role codes and require at least one explicit Owner;
- run post-import reconciliation for legacy billing and retired birth-time rectification rows;
- emit only counts, state aggregates, and normalized SHA-256 manifests—not email addresses, birth data, tokens, or connection strings;
- run all target writes in a transaction and roll back on failure.
The operator supplies these values only on the trusted migration host; do not store the database URLs or encryption keys in Gitea:
SUPABASE_SOURCE_DATABASE_URL: the consistent read-only Supabase snapshot/source URL;PRODUCTION_TARGET_DATABASE_URL: the PostgreSQL 17 target URL using the migration role;PRODUCTION_OWNER_USER_ID: the UUID of the designated active source administrator;PRODUCTION_OWNER_EMAIL: the canonical email that must match that UUID in sourceauth.users;PRODUCTION_CIPHERTEXT_MODE=preserve|exclude; preserve additionally requiresPRODUCTION_CIPHERTEXT_KEYS_CONFIRMED=true.
Run each phase separately and retain its redacted JSON manifest:
cd frontend
node scripts/migrate-supabase-production.mjs --preflight
node scripts/migrate-supabase-production.mjs --apply
node scripts/migrate-supabase-production.mjs --verify
--apply is intentionally one-shot: it refuses a populated target. If apply fails, discard or restore the isolated target, correct the cause, and rerun from an empty migrated schema rather than improvising a partial resume.
Do not import platform schemas, source roles/grants, Supabase migration ledgers, sessions, refresh tokens, or provider tokens. Do not use a full-database pg_restore against the target.
Because migrated users have no portable password/session, all sessions are invalidated and users sign in again through email OTP. Administrators re-enrol MFA.
Rehearsal
On 2026-08-09, an isolated PostgreSQL 17 rehearsal completed --preflight, --apply, and --verify against a read-only production Supabase transaction. It reconciled 85 identities and all 18 selected non-empty/seeded legacy public tables by count, primary-key hash, normalized row hash, credit totals, consultation states, and rectification counts. This is rehearsal evidence only; it does not authorize the final write freeze, production import, or DNS change.
Complete the remaining runtime and restore rehearsal before scheduling the final window:
- Apply all target schema migrations to an empty rehearsal database using the same schema migrator path as
Migrate Production Database. - Run migration preflight, apply, post-import reconciliation, and verify.
- Verify source/target row counts, primary-key set hashes, normalized row hashes, credit totals, payment state totals, subscriptions, reports, consultations, and rectification records.
- Verify one Owner exists, every active admin has a target role, and database roles remain isolated.
- Test OTP login, historical balance and history reads, admin login/RBAC, report generation/export, payment callback rejection/idempotency, and model provider access.
- Create an encrypted backup, restore it into a separate database, and repeat smoke checks.
- Record the observed export/import/verification duration and use it to set the maintenance window.
For the 2-core/4-GB host, keep database pools bounded (recommended starting maxima: identity 5, app 5, admin 3) rather than allowing three pools of 10 to consume all 30 PostgreSQL connections.
DNS preparation
At least one current TTL period before cutover, reduce relevant Spaceship TTLs to 300. Do not change record targets yet. Check both authoritative nameservers and remove any legacy AAAA record that points elsewhere.
Final records are:
| Type | Host | Value |
|---|---|---|
A |
@ |
118.194.235.34 |
A |
admin |
118.194.235.34 |
CNAME |
www |
jyotisha.chat |
Both jyotisha.chat and admin.jyotisha.chat are required. The application rejects unknown identity hosts, and the user domain intentionally hides /admin and /api/admin/*.
Cutover sequence
T-24 hours
- Confirm the exact release SHA is deployed and accepted on staging.
- Run the manual release quality gate for that SHA.
- Confirm final backup capacity, restore rehearsal, SMTP/OTP delivery, and rollback contacts.
- Create and restore-verify a production recovery point no more than 24 hours before the schema migration; record its non-sensitive reference and UTC creation time.
- Dispatch
Migrate Production Databasefor the accepted SHA with that recovery attestation and confirm its post-check reports no pending schema migrations. - Record pending payment orders and long-running jobs; choose an explicit disposition for each.
- Dispatch
Deploy productionwithverification_mode=internalonly after target schema/data preparation. This verifies the new host without depending on public DNS.
Maintenance freeze
- Set payment/package creation off and keep
EPAY_CHAT_ENABLED=false. - Put old production in maintenance mode and stop Web/Agent/background writes.
- Disable new Supabase registrations for the window.
- Confirm source row counts stop changing.
- Take the final encrypted source backup and one consistent source snapshot.
- Run the production ETL once, then post-import reconciliation and verification.
- Verify role-specific
SELECT 1connectivity for identity, app, service, and admin roles; verify permission isolation separately. - Keep the new site in maintenance mode while running user/admin/report/payment smoke checks.
Do not attempt an ad-hoc full-plus-incremental migration. Several tables lack a common updated_at or soft-delete contract, so an improvised delta can lose deletes, refunds, or accounting changes.
DNS and public verification
- Change the three Spaceship records only after all final data assertions pass.
- Verify both authoritative nameservers, then public recursive resolvers.
- Wait for Caddy certificates for both user and admin hosts.
- Dispatch the same exact SHA with
verification_mode=public. - Verify OTP login, logged-out account
401, user-host admin paths404, admin-host unauthenticated behavior, health SHA, report generation/export, and one controlled payment callback test. - Re-enable public writes. Re-enable payment only after DNS convergence and callback verification.
Go/no-go assertions
Cutover is no-go if any of these conditions is true:
main,staging, staging health, gate artifact, or requested SHA differs;- any migration or manifest checksum is unresolved;
- source/target identity, balance, order, subscription, or report reconciliation differs;
- there is no active Owner or an active admin has no role;
- migrated banned users are not accounted for;
- a production encryption key/ciphertext decision is unresolved;
- backup restore has not been demonstrated;
- role-specific database readiness or isolation fails;
- either user/admin TLS host is unavailable;
- pending payment writes or callbacks can still reach the old writable database.
Rollback boundary
Before the new database accepts real writes, rollback is: restore old DNS targets, keep the old site/Supabase authoritative, and investigate the isolated target.
After the new database accepts real writes, a DNS-only rollback is unsafe. First stop new writes, reconcile the new PostgreSQL delta back to the chosen authority, and obtain an explicit operator decision. Otherwise post-cutover users, orders, credits, and reports can be lost.
Keep the old VPS in maintenance/read-only mode for at least 7–14 days and retain Supabase for 14–30 days. Do not destroy either immediately after DNS cutover.
Workflow dispatch
Normal release:
- Merge the reviewed
stagingrelease intomainso both heads are the same SHA. - Confirm the staging push gate, public staging SHA, and manual release gate all succeeded for that SHA.
- Open Gitea Actions →
Migrate Production Database; enter the exact 40-character SHA, the no-more-than-24-hour-old recovery reference and UTC creation time, and confirmrestore_verified=true. Wait for the post-migration checker to converge. Do not use this workflow for Supabase ETL. - Run the trusted-host ETL phases and retain the redacted reconciliation manifests.
- Open Gitea Actions →
Deploy production. - Enter the same exact SHA, leave
allow_rollback=false, and chooseinternalorpublicfor the current cutover phase.
Application rollback accepts only an explicitly authorized, previously gate-attested SHA in reviewed main history. Database migrations and imported data are not rolled back by the application workflow.