How Ansari's backend goes from develop to production. This codifies the
process that was previously in architect notes; the smoke sequence and the
migration runbook were both proven in real releases on 2026-08-02.
-
developis the integration branch. It is protected: PRs only, with the required CI checks from.github/workflows/ci.yml—backend (lint, typecheck, test, build),frontend (lint, typecheck), andgitleaks (secret scan). Of these,develop's protection currently requiresbackend (lint, typecheck, test, build)andgitleaks (secret scan).Naming note (spec 48) — deferred follow-up, not an action before merge. The first check's name looks stale and is stale on purpose. The monorepo restructure moved the backend app under
apps/api/and renamed that CI job's ID toapi, but its emitted name is deliberately left asbackend (lint, typecheck, test, build).Required status checks are matched by name. Changing what the job emits would mean the required check never reports again, leaving every PR unmergeable until a repo admin edits the protection rule — and a job's ID and emitted name are independent, so keeping the name costs nothing.
Renaming it is a follow-up that must be done as one coordinated change: edit
develop's required-check name and thename:line in.github/workflows/ci.ymltogether. It needs repo-admin access. Do not change either half on its own.
Both services build with the repo root as their Root Directory: the pnpm workspace needs the root
pnpm-lock.yaml, and both Dockerfiles now alsoCOPY packages packages. Do not set Root Directory to the app folder.
Service Dockerfile path backend apps/api/Dockerfilefrontend (web) apps/frontend/Dockerfile.webauth apps/auth/DockerfileWatch paths — set these per service, or Railway rebuilds both on every push (any commit touches the root lockfile):
backend: apps/api/** packages/** package.json pnpm-lock.yaml pnpm-workspace.yaml frontend: apps/frontend/** packages/** package.json pnpm-lock.yaml pnpm-workspace.yaml auth: apps/auth/** packages/** package.json pnpm-lock.yaml pnpm-workspace.yaml
packages/**is not optional. The shared config packages are copied into both images, so a packages-only change alters the built artifact. Omit the pattern and that change ships nothing — no rebuild, no deploy, and no error. The image simply drifts from the repo.Deploy settings — backend healthcheck
/api/health(timeout 300s), frontend healthcheck/, auth healthcheck/health(200 only when the auth database answers; timeout 300s); restart ON_FAILURE, max 3 retries.auth service (spec 300) — one listener: the public domain (
auth.askansari.ai,auth-staging.askansari.ai) targetsPORT(3100); set it as a service variable.backendreaches it at that domain too (AUTH_URL), like any client: no private-network hop, no shared secret (spec amendment H-1). It has its OWN Railway Postgres, the auth database, with snapshots or PITR enabled before its first real row, on Postgres 18 (C-4: the owner's service; in staging it already exists, empty; in production it is created or found, and checked empty in window step 1). Its image is the service only: no Postgres client and no migration SQL. The cutover runs from the owner's machine over the Tailscale route (spec amendment H-8; the runbook's "operator environment"). The two operator scripts run from a checkout with the auth database'sDATABASE_URL(pnpm exec tsx scripts/grant-admin.ts …,… scripts/ban-user.ts …), or in the image from/repo/apps/authwith./node_modules/.bin/tsx scripts/…(tsx directly: the image has no pnpm for its non-root user).frontend (web) build variables —
Dockerfile.webbakesEXPO_PUBLIC_API_URLandEXPO_PUBLIC_AUTH_URLinto the bundle at build time, so both are set on the Railway service and a change needs a redeploy. They name the SAME environment: productionhttps://api.askansari.ai+https://auth.askansari.ai, staginghttps://api-staging.askansari.ai+https://auth-staging.askansari.ai(the defaults when unset). The web app signs in atEXPO_PUBLIC_AUTH_URL(spec 300).
apps/*/railway.tomlare not read by Railway here. This project configures deployment through the dashboard with a Dockerfile path (confirmed by the operator, 2026-08-21). Railway does support a config-as-code path setting; this project does not use it. The tomls record the same values in git so the intended configuration stays reviewable — update them in the same PR whenever you change a dashboard setting, or the record drifts from reality.
mainis the production branch. The Railway servicebackend(a name in the Railway dashboard — unrelated to theapps/api/directory, and not stale) auto-deploys every push tomain. Its effective settings are in the block above: root directory = repo root, dockerfile build fromapps/api/Dockerfile, healthcheck/api/healthwith a 300s timeout, restartON_FAILUREup to 3 times.apps/api/railway.tomlrecords those same values in git.- Promotion is a fast-forward, nothing else.
mainis moved todevelop's tip with--ff-only— no merge commits, no cherry-picks, somainis always an exact prefix ofdevelop's history.
-
CI green on the
developtip you intend to ship. -
Pre-promotion local smoke test passes (next section) on that same tip.
-
Explicit human go. A human says "release" — green CI alone is never a reason to promote.
-
Promote (fast-forward only):
git fetch origin git checkout main git pull --ff-only git merge --ff-only origin/develop git push origin main
If
--ff-onlyrefuses, stop:mainhas diverged. Investigate — never force-pushmain. -
Watch the Railway auto-deploy until the new deployment is live.
/api/healthis a real deploy gate: it returns 200 only when the database answersSELECT 1(within 2s) and 503 otherwise, so a deploy with a brokenDATABASE_URLfails its healthcheck instead of going live broken. -
Health-check both production domains (both serve the Railway
backendservice):curl -fsS https://api.askansari.ai/api/health curl -fsS https://api-35.ansari.chat/api/health
Both must return 200 with
{"status":"ok","service":"ansari-backend",...}. -
Sentry watch, 15 minutes. Watch the backend Sentry project for new or spiking issues (wired via
apps/api/sentry.server.config.ts). Only after a quiet 15 minutes is the release considered done. -
Anything wrong → Rollback (last section).
Proven 2026-08-02; rewritten for Better Auth (spec 300, closes #33). Run on the exact
develop tip you are about to promote, with real env files for both services (see the env
tables in docs/self-hosting.md — the chat step needs real AI/search keys, because it
exercises the real model and tools, not mocks). apps/api no longer signs anyone in: it
verifies JWTs from apps/auth, so both services run, each on its own database.
-
Build (from
apps/api/):(cd ../.. && pnpm install) && pnpm build && (cd ../auth && pnpm build)
-
Migrate two fresh databases. Start a disposable Postgres 18 (the production major), create the app and auth databases, and apply both full journals to them — this proves a from-scratch install works, not just the incremental step:
docker run --rm -d --name ansari-smoke -e POSTGRES_PASSWORD=postgres -p 5433:5432 postgres:18 psql postgresql://postgres:postgres@localhost:5433/postgres -X -c 'create database ansari_auth' export DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres pnpm db:migrate DATABASE_URL=postgresql://postgres:postgres@localhost:5433/ansari_auth pnpm --filter @ansari/auth db:migrate
pnpm db:migrateappliesapps/api/drizzle/0000_baseline.sql→ latest to the app database; the second command appliespackages/auth/drizzle/(from0000_auth_schema.sql) to the auth database. -
Boot both production builds.
apps/authfirst, in its own shell, fromapps/auth/, with itsDATABASE_URLset to the auth database and its env file loaded (it listens on 3100):DATABASE_URL=postgresql://postgres:postgres@localhost:5433/ansari_auth pnpm start
Then
apps/api, in this shell (theDATABASE_URLoverride wins over.env; itsAUTH_URLishttp://localhost:3100,apps/auth's origin):pnpm start
-
Health:
curl -fsS localhost:3000/api/health # 200 {"status":"ok",...} curl -fsS localhost:3100/health # 200 {"status":"ok"}
-
Auth round-trip, both paths. Use a strong throwaway passphrase (the strength policy rejects weak passwords with a 400; 8 to 128 characters).
The compatibility layer the released mobile app uses (
apps/apicallsapps/auth):curl -s localhost:3000/api/v2/users/register -H 'Content-Type: application/json' \ -d '{"email":"smoke@example.com","password":"<strong throwaway passphrase>"}' TOKEN=$(curl -s localhost:3000/api/v2/users/login -H 'Content-Type: application/json' \ -d '{"email":"smoke@example.com","password":"<same passphrase>"}' | jq -r .access_token) curl -s localhost:3000/api/v2/users/me -H "Authorization: Bearer $TOKEN"
Directly at
apps/auth, asapps/frontenddoes (sign in, then exchange the session for a JWT; theOriginis one ofapps/auth'sCORS_ORIGINvalues):SESSION=$(curl -s localhost:3100/api/auth/sign-in/email -H 'Content-Type: application/json' \ -H 'Origin: http://localhost:8081' \ -d '{"email":"smoke@example.com","password":"<same passphrase>"}' | jq -r .token) JWT=$(curl -s localhost:3100/api/auth/token -H "Authorization: Bearer $SESSION" | jq -r .token) curl -s localhost:3000/api/v2/users/me -H "Authorization: Bearer $JWT"
Both
mecalls return the same id. -
Real streamed chat with tool use:
THREAD=$(curl -s -X POST localhost:3000/api/v2/threads \ -H "Authorization: Bearer $TOKEN" | jq -r .thread_id) curl -N -s localhost:3000/api/v2/threads/$THREAD/chat \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{"message":"What does the Quran say about patience?"}'
Expect a live SSE stream showing tool activity followed by a substantive, cited answer. A Quran/hadith question reliably triggers tool use.
-
Admin bootstrap (#33): from
apps/auth/, with the auth database'sDATABASE_URL, grant the smoke account and confirm the admin route answers:DATABASE_URL=postgresql://postgres:postgres@localhost:5433/ansari_auth \ pnpm exec tsx scripts/grant-admin.ts smoke@example.com curl -s -o /dev/null -w '%{http_code}\n' localhost:3000/api/v2/admin/stats -H "Authorization: Bearer $TOKEN" # 200
-
Teardown:
docker stop ansari-smoke(the container was started with--rm).
Use this variant whenever the release includes a schema change: a new file in
apps/api/drizzle/ (the app database) or in packages/auth/drizzle/ (the auth
database, spec 300). The two databases are separate Railway Postgres services; every step
below names the one it touches, as APP_DATABASE_URL or AUTH_DATABASE_URL. Those two names
exist only in the operator's Railway shell, for that session: never on a service, never in
a file. Each service's own variable is DATABASE_URL.
NEVER run
pnpm db:push(drizzle-kit push) against production. It diffs the live schema and can drop or recreate tables — and data. The rule for schema changes is:pnpm db:generate→ review the generated SQL → apply that SQL explicitly at release time (below).pnpm db:migrateis for local/fresh databases (as in the smoke test above); production gets the reviewed SQL viapsql.
Order proven for migration 0003 on 2026-08-02:
inspect → apply via psql → bootstrap → deploy.
-
Inspect production data the migration (especially any backfill) will touch, via
psql— e.g. before0003, the rows its system-account backfill would match. Know what the migration will do to real rows before it does it. -
Apply the reviewed SQL explicitly, from the
developcommit being promoted, and record that commit with the file name in the release notes:psql "$APP_DATABASE_URL" -X -v ON_ERROR_STOP=1 --single-transaction -f apps/api/drizzle/000N_<name>.sql psql "$AUTH_DATABASE_URL" -X -v ON_ERROR_STOP=1 --single-transaction -f packages/auth/drizzle/000N_<name>.sql
Both flags, on every apply, for both databases.
ON_ERROR_STOPalone stops at the first error but leaves the statements before it committed;--single-transactionrolls them back too, so a failed migration applies nothing. A statement that cannot run inside a transaction (CREATE INDEX CONCURRENTLYand similar) goes in a migration file of its own, applied without--single-transaction, and its PR says so. A migration's own guard (a check that must abort the migration) is written as oneDOblock, so it aborts the file whatever flags it is run with. For the auth journal, recordauth-journal-applied: <tag>in the release notes with the commit. -
Run bootstrap scripts (after the migration, before the deploy):
- Admins live in
apps/auth(spec 300): fromapps/auth/, with the auth database'sDATABASE_URL,pnpm exec tsx scripts/grant-admin.ts <email>(in the auth service's Railway shell:./node_modules/.bin/tsx scripts/grant-admin.ts <email>). It grants the admin role to an account that already exists, in two steps:grant-admin.ts <email>shows the account (id, email, created_at, email_verified, role) and grants nothing;grant-admin.ts <email> --confirm <id>grants exactly that account. It never creates an account or touches a password, and it refuses the system domain. A new admin signs up first; a warning on a recent or unverified account means: confirm the person out of band before--confirm. There is noADMIN_EMAILSonapps/auth(spec H-7): the role in the auth database is the only admin truth. - System accounts (
ai-skill,leaderboard) self-provision lazily bysystem_key— no manual step.
- Admins live in
-
Then run the standard release checklist above. Nothing at
apps/auth's boot checks admins (spec H-7); verify them withgrant-admin.ts --list(or the cutover's05-audit-admins-auth.sql).
Migrations must be backward-compatible with the currently-deployed code: the SQL is applied while the previous deployment is still serving traffic.
The Better Auth cutover is the exception (spec 300): it runs in a maintenance window, with
maintenance mode on and web access off while auto-deploy stays on, by its own runbook, docs/cutover/better-auth-cutover.md,
with its rollback in docs/cutover/rollback.md. From the merge of
the cutover PR to the end of the window, no other migration merges to develop, in either
journal (schema freeze).
The contract migration is the second exception. The cutover drops nothing the legacy code
reads, so the 24-hour rollback stays a redeploy. The destructive half (dropping
public.users.password_hash, is_admin and session_version, and the tokens table, in the
app database) is its own reviewed PR, merged and applied only after the rollback deadline,
because it is not backward-compatible with the previous deployment and rollback cannot survive
it. Its checklist also removes backend's legacy variables (JWT_SECRET,
ACCESS_TOKEN_EXPIRY_HOURS, REFRESH_TOKEN_EXPIRY_HOURS, ADMIN_EMAILS), which stay set until
then so the rollback can boot the previous apps/api.
No app migration is generated between the cutover merge and the contract PR. The latest
apps/api drizzle snapshot (0010_snapshot.json) still records tokens and the three legacy
users columns, and the hand-written 0011 has no snapshot. Any drizzle-kit generate for
apps/api in that interval therefore emits their drops as a surprise diff, inside an unrelated
migration that would run before the rollback deadline. The contract PR is the first generate; its
SQL is checked in review to contain only those drops (the three columns and tokens), and
nothing else.
- Code: Railway dashboard →
backendservice → Deployments → select the previous good deployment → Redeploy. No git operation required. Re-run the health checks on both domains afterwards. Follow up by reverting the offending change ondevelopthrough the normal PR flow — never by force-pushingmain. - Within 24 hours of the Better Auth cutover: follow
docs/cutover/rollback.mdinstead (it also removes the users created after the cutover). After that deadline, fix forward. - Database: migrations are not rolled back. They are applied manually and
must be backward-compatible, so the previous code runs fine on the new
schema. If a migration itself is bad, ship a corrective forward migration —
never
db:push.
Native binaries and over-the-air updates for apps/frontend are produced on
EAS, dispatched from GitHub Actions. No laptop is involved, and this is the
only supported path. Each GitHub workflow is a thin dispatcher: it installs the
workspace at the repo root (the EAS CLI evaluates the app config on the
runner) and runs eas workflow:run on an EAS Workflow in
apps/frontend/.eas/workflows/. Follow the run from the EAS URL printed in
the dispatcher's last step. EAS Build goes green once EAS accepts the
run, not once the build finishes. EAS Update runs with --wait, so
it goes green only when the publish finishes, and fails if the publish fails.
Build: Actions tab → EAS Build (.github/workflows/eas-build.yml) →
Run workflow → pick platform (all | ios | android) and profile:
| Profile | Produces | Update channel | GitHub Environment |
|---|---|---|---|
development |
dev-client build, internal distribution | development |
development |
preview |
release build, internal distribution | preview |
preview |
production-internal |
production build, internal distribution | production |
production |
production |
store build; build number auto-increments | production |
production |
Update: Actions tab → EAS Update (.github/workflows/eas-update.yml) →
pick platform and channel (development | preview | production). The
channel also names the EAS environment and the GitHub Environment.
- Build numbers are managed by EAS.
eas.jsonsetsappVersionSource: remote, so the iOS build number and Android version code live on EAS, not inapp.json. Onlyproductionauto-increments them. runtimeVersion: fingerprintdecides which binaries an update reaches. An update is delivered only to builds on its channel whose native fingerprint matches the commit it was published from. After any native change (a dependency with native code, a config plugin,app.jsonnative fields), ship a new build first; an update alone reaches no one. The fingerprint contract is tracked in #267.- Concurrent dispatches queue, they never cancel (
queue: max, so every pending run is kept). Build serializes the dispatch per profile/platform pair, not the EAS build. The dispatcher does not wait, so two builds of one pair can still run on EAS at the same time. Update serializes per channel (all platforms) and waits for each EAS run, so updates to a channel publish in dispatch order.
Token and Environments (prerequisite, not yet done). No EXPO_TOKEN
exists yet, and neither do the three Environments (the repo's existing
Environments are Railway's). Do these steps in this order:
- Create the GitHub Environments
development,previewandproduction. Each gets a deployment branch policy that allows onlydevelop.productionalso gets required reviewers, so every production build or update waits for a human approval. - Only then add the token. Create an Expo robot access token with
access to the
ansari-project/ansari-chatEAS project, and store it as theEXPO_TOKENsecret in each of the three Environments. - Only then dispatch. A dispatch that names an Environment which does not exist yet auto-creates it unprotected, with no branch policy and no reviewer. Dispatching before step 1 would therefore create exactly the hole this setup closes.
The branch policy is what makes the reviewer meaningful. A workflow_dispatch
runs the selected branch's own copy of the workflow. Without a branch
restriction, anyone who can push a branch can edit the dispatcher there,
dispatch it against the development environment (no reviewer), and send the
same token to eas with a production profile or channel. The production
reviewer would never be asked. Restricting every Environment to develop
means only reviewed workflow code ever receives the token. The dispatchers are
the only place the token is used.