Files
Jyotisha/docs/operations/production-server-migration-2026-08.md
T
Codex 9b07e12b0c
Staging Backend Quality Gate / validate (push) Has been cancelled
Staging Backend Quality Gate / publish (push) Has been cancelled
ops: prepare self-hosted production migration
2026-08-08 21:12:55 -04:00

196 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
## Release invariants
The production workflow is manual-only and accepts a full lowercase 40-character `deploy_sha`. A normal deployment proceeds only when all of the following identify that exact SHA:
1. current `main`;
2. current `staging`;
3. a successful push-triggered `Staging Backend Quality Gate`;
4. a successful manually triggered `Jyotish Release Quality Gate`;
5. the public staging `/api/health` deployment identity.
The 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.
## Required Gitea configuration
Repository variables:
| Name | Required value |
| --- | --- |
| `PRODUCTION_HOST` | `118.194.235.34` |
| `PRODUCTION_PORT` | Confirmed SSH port; do not assume `22` |
| `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_USERNAME` and `REGISTRY_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:
1. Patch Ubuntu and install Docker Engine, Compose v2, `rsync`, `curl`, `jq`, `flock`, and UFW.
2. Create a non-root `deploy` user, install a dedicated Ed25519 public key, and grant only the reviewed passwordless commands needed for Docker and deployment-tree ownership.
3. Verify a second key-only session, then disable root login and password authentication and rotate the bootstrap password.
4. Permit only the confirmed SSH port plus `80/tcp`, `443/tcp`, and `443/udp`. Do not publish `3000`, `5200`, `5432`, or the Docker API.
5. Create a 24 GB swap file and enable Docker log rotation. Keep at least 15 GB free before the first image pull and database import.
6. Create `/opt/jyotisha-production`, owned by `deploy`, and preload the reviewed `postgres:17-alpine` and Caddy images. PostgreSQL image upgrades are separate maintenance operations.
Create these host-only files with owner `deploy` and mode `0600`:
```text
/opt/jyotisha-production/.env.production
/opt/jyotisha-production/.env.production.database
```
The application selectors must be exact:
```dotenv
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 production cutover, implement and review `frontend/scripts/migrate-supabase-production.mjs` with `--preflight`, `--apply`, and `--verify` modes. Until that tool and its fixtures pass, production data cutover is blocked.
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.users` into 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.
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
Complete at least one isolated full-data rehearsal before scheduling the final window:
1. Apply all target schema migrations to an empty rehearsal database.
2. Run migration preflight, apply, post-import reconciliation, and verify.
3. Verify source/target row counts, primary-key set hashes, normalized row hashes, credit totals, payment state totals, subscriptions, reports, consultations, and rectification records.
4. Verify one Owner exists, every active admin has a target role, and database roles remain isolated.
5. Test OTP login, historical balance and history reads, admin login/RBAC, report generation/export, payment callback rejection/idempotency, and model provider access.
6. Create an encrypted backup, restore it into a separate database, and repeat smoke checks.
7. 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.
- Record pending payment orders and long-running jobs; choose an explicit disposition for each.
- Dispatch `Deploy production` with `verification_mode=internal` only after target schema/data preparation. This verifies the new host without depending on public DNS.
### Maintenance freeze
1. Set payment/package creation off and keep `EPAY_CHAT_ENABLED=false`.
2. Put old production in maintenance mode and stop Web/Agent/background writes.
3. Disable new Supabase registrations for the window.
4. Confirm source row counts stop changing.
5. Take the final encrypted source backup and one consistent source snapshot.
6. Run the production ETL once, then post-import reconciliation and verification.
7. Verify role-specific `SELECT 1` connectivity for identity, app, service, and admin roles; verify permission isolation separately.
8. 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
1. Change the three Spaceship records only after all final data assertions pass.
2. Verify both authoritative nameservers, then public recursive resolvers.
3. Wait for Caddy certificates for both user and admin hosts.
4. Dispatch the same exact SHA with `verification_mode=public`.
5. Verify OTP login, logged-out account `401`, user-host admin paths `404`, admin-host unauthenticated behavior, health SHA, report generation/export, and one controlled payment callback test.
6. 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 714 days and retain Supabase for 1430 days. Do not destroy either immediately after DNS cutover.
## Workflow dispatch
Normal release:
1. Merge the reviewed `staging` release into `main` so both heads are the same SHA.
2. Confirm the staging push gate, public staging SHA, and manual release gate all succeeded for that SHA.
3. Open Gitea Actions → `Deploy production`.
4. Enter the exact 40-character SHA, leave `allow_rollback=false`, and choose `internal` or `public` for 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.