Deploy
Topology
| Component | Host | Notes |
|---|---|---|
API (apps/api) | Railway | Containerized via the project’s Nx webpack build. |
Web (apps/web) | Vercel | Next.js 16 app; vercel.json at repo root selects the project. |
Admin (apps/admin) | Vercel | Standalone Vite app; apps/admin/vercel.json. |
Marketing (apps/marketing) | Vercel (apps/marketing/vercel.json) | Astro site. |
Minisites (apps/minisites) | Vercel | Astro multi-tenant; custom domains attached programmatically via Vercel API. See VERCEL_TOKEN, VERCEL_MINISITES_PROJECT_ID. |
| Postgres | Managed (Railway) | pgvector extension required. |
| Redis | Managed | Used for BullMQ, Socket.IO adapter, Spotter rate limit. |
| R2 | Cloudflare | Two buckets: general (R2_BUCKET_NAME) and compliance (R2_COMPLIANCE_BUCKET_NAME, used for FIT-158 signed PDFs with object-lock). |
Promotion path
PR (smoke + e2e gates) → main → Railway/Vercel auto-deploy (API runs db:migrate at start) → smoke
↑ CD Full Test Gate validates main on manual dispatch1. PR opened
ci-smoke-tests.yml runs:
- DB migration against a fresh test DB.
- API unit + integration + e2e (skipped for web-only PRs).
- Web unit + integration (skipped for api-only PRs).
- Playwright web E2E (separate job, runs once smoke is green).
Required to pass before merge.
2. Merge to main
cd-full-test-gate.yml re-runs the full suite (including Playwright web E2E) against main on manual dispatch. PR checks already ran the same suites against the merge commit, so this is only needed when that assumption breaks: risky deploys, direct pushes to main, or a merge whose checks ran against a stale base. A red run means main is broken and blocks further deploys.
3. Auto-deploy
- API — Railway watches
mainand builds the api project. Thenx build apioutput is shipped to a Node container. - Web — Vercel watches
mainand buildsapps/web. - Admin / minisites / marketing — same pattern, per project.
- Order: API before web. API and web deploy independently from the same
maincommit. When a PR changes an API response the web reads, let Railway finish (deployment live,/health200) before the Vercel build is promoted; if Vercel finishes first, hold its promotion until the API is live. Clients are written to tolerate the reverse window (e.g. holdings fall back toplan.typeviaholdingKindwhenkindis missing), so this is belt and braces, not a hard dependency. - Release commits are skipped. Each
vercel.jsonsetsignoreCommandso Vercel skips commits whose message starts withchore(shared): release v. That bot commit only bumpslibs/shared/package.json, so there is nothing to deploy. A skipped build shows as “Canceled” in Vercel, not as an error.
4. Migrate the database
The Railway deployment runs pnpm db:migrate itself as the first step of the API’s start command, before the new image serves traffic. The deploy log shows nx run @taikan/db:migrate followed by migrations applied successfully. A failing migration fails the deploy and Railway keeps the previous image serving.
That makes two things load-bearing for every migration-carrying PR:
- A migration must be safe against the data prod already holds. A CHECK or NOT NULL that existing rows violate fails the whole batch. Ship the backfill as its own drizzle-kit custom migration between the column and the constraint, and run any read-only pre-check the PR documents against prod before merging.
- The journal must stay strictly monotonic (see migrations.md); a stale
whenmakes drizzle report success while applying nothing.
Manual pnpm db:migrate against prod is only for recovery, from a machine with the prod DATABASE_URL, and only with the owner’s approval.
5. Smoke
After Railway/Vercel swap:
GET https://<api>/health→ 200GET https://app.taikan.fit/en→ 200, locale set, Clerk session loads- Sign in as a test member → dashboard renders
- Spotter chat dock opens (coach persona) → first turn returns
If any step fails, see incident-response.md.
6. Post-deploy data backfills
Run any one-off backfill the PR lists, after the API that writes the new data is live, from a machine with the prod DATABASE_URL exported in the shell (backfill scripts do not read .env). Always dry-run first and check the printed Target: host/database, then apply with a manifest kept somewhere durable.
memberships.end_date(taikan#586):scripts/backfill-membership-end-date.ts.DATABASE_URL=… pnpm exec tsx scripts/backfill-membership-end-date.ts. Each row prints its rule (left,join_link_never_paid,join_shell_discarded,invitation_revoked) and currentjoined_at; per-rule counts follow. Review the never-joined rows: theirjoined_atbecomes NULL.… --apply --manifest <file>. One transaction,statement_timeout 5min,lock_timeout 10s; a lock timeout aborts with nothing written, just re-run.- A second
--applymust report 0 rows. - To undo:
… --rollback <file>(dry run), then… --rollback <file> --apply. It restoresend_date/joined_atand refuses if any row changed since.
Rollback
| What | How |
|---|---|
| API | Railway → previous deployment → “Roll back”. |
| Web | Vercel → previous deployment → “Promote to production”. |
| DB | No down-migrations. Either forward-fix or restore from the managed snapshot (Railway backups). See migrations.md. |
Environment variables in prod
Set in Railway (API, marketing) and Vercel (web, admin, minisites) project settings. Never check production keys into the repo or .env.example — the file holds only sandbox/test placeholders.
Key production-only vars:
DATABASE_URL(PgBouncer pool) +DIRECT_DATABASE_URL(direct, for migrations)REDIS_URLCLERK_SECRET_KEY(prod instance,sk_live_*)CLERK_WEBHOOK_SECRET(prod webhook endpoint)PAYMENT_CREDENTIALS_ENCRYPTION_KEY,NATIONAL_ID_ENCRYPTION_KEY— see credentials-rotation.mdINVITE_SECRET,QR_CHECKIN_SECRETCARDCOM_*real terminal,PLATFORM_BILLING_*Taikan’s own Cardcom credentials,MORNING_*prod hostsR2_*prod bucket credentials,R2_COMPLIANCE_BUCKET_NAME(object-locked)VERCEL_TOKEN,VERCEL_MINISITES_PROJECT_ID— minisite domain provisioningSENTRY_DSN,SENTRY_RELEASE=$RAILWAY_GIT_COMMIT_SHA,SENTRY_AUTH_TOKEN(build-time, for source-map upload)POSTHOG_API_KEY,POSTHOG_PROJECT_ID— server-side event ingestion + admin observabilityCRONS_ENABLED=true— opt scheduled jobs back in (off by default in.env.example)
Deploy gates
A PR should not be merged if:
- CI is red.
- The PR adds a migration but the description doesn’t explain rollback.
- The PR removes a
data-testidthat’s referenced inapps/web/e2e/. - The PR adds a new env var without updating
.env.example. - The PR adds a user-facing string without keys in all three of
en.json,he.json,ru.json.
These are conventions, not enforced gates — code review is the gatekeeper today.