Skip to Content
Living documentation — last reviewed 2026-05-28
RunbooksDeploy

Deploy

Topology

ComponentHostNotes
API (apps/api)Render (taikan-api)Docker web service, Frankfurt, 2 Standard instances. Built from apps/api/Dockerfile with the repo root as context.
Web (apps/web)VercelNext.js 16 app; vercel.json at repo root selects the project.
Admin (apps/admin)VercelStandalone Vite app; apps/admin/vercel.json.
Marketing (apps/marketing)Vercel (apps/marketing/vercel.json)Astro site.
Minisites (apps/minisites)VercelAstro multi-tenant; custom domains attached programmatically via Vercel API. See VERCEL_TOKEN, VERCEL_MINISITES_PROJECT_ID.
PostgresRender (taikan-db)PG 18, plan 1c-2g, Frankfurt. Direct connections, no pooler. External access is IP-allow-listed. pgvector extension required.
RedisRender Key Value (taikan-redis)Plan 1g, noeviction. Used for BullMQ, Socket.IO adapter, Spotter rate limit.
R2CloudflareTwo 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 → Render/Vercel auto-deploy (API runs db:migrate as its pre-deploy step) → smoke ↑ CD Full Test Gate validates main on manual dispatch

1. 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 — Render auto-deploys taikan-api on every commit to main: it builds apps/api/Dockerfile (the Nx build plus an esbuild bundle) and rolls the two instances once /health/live answers. The Dockerfile must stay out of the repo root (see the comment at its top).
  • Web — Vercel watches main and builds apps/web.
  • Admin / minisites / marketing — same pattern, per project.
  • Order: API before web. API and web deploy independently from the same main commit. When a PR changes an API response the web reads, let Render finish (deploy live, /health 200) 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 to plan.type via holdingKind when kind is missing), so this is belt and braces, not a hard dependency.
  • Release commits are skipped. Each vercel.json sets ignoreCommand so Vercel skips commits whose message starts with chore(shared): release v. That bot commit only bumps libs/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 Render deploy runs pnpm db:migrate itself as the service’s pre-deploy command, in the new image, before any new instance takes traffic. The deploy log shows nx run @taikan/db:migrate followed by migrations applied successfully. A failing migration fails the deploy and Render keeps the previous deploy 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 when makes 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 Render/Vercel swap:

  • GET https://<api>/health → 200
  • GET 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.
    1. 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 current joined_at; per-rule counts follow. Review the never-joined rows: their joined_at becomes NULL.
    2. … --apply --manifest <file>. One transaction, statement_timeout 5min, lock_timeout 10s; a lock timeout aborts with nothing written, just re-run.
    3. A second --apply must report 0 rows.
    4. To undo: … --rollback <file> (dry run), then … --rollback <file> --apply. It restores end_date / joined_at and refuses if any row changed since.

Rollback

WhatHow
APIRender → taikan-api → Events → previous deploy → “Rollback”. The rollback does not undo migrations that deploy applied.
WebVercel → previous deployment → “Promote to production”.
DBNo down-migrations. Either forward-fix or restore: Render point-in-time recovery, or the 6-hourly dump in R2. See restore-from-backup.md and migrations.md.

Environment variables in prod

Set in the Render taikan-api service environment (API) and Vercel project settings (web, admin, marketing, minisites). Never check production keys into the repo or .env.example — the file holds only sandbox/test placeholders.

Key production-only vars:

  • DATABASE_URL + DIRECT_DATABASE_URL — both the direct internal URL of taikan-db (no pooler; see database.md)
  • REDIS_URL — internal URL of taikan-redis
  • CLERK_SECRET_KEY (prod instance, sk_live_*)
  • CLERK_WEBHOOK_SECRET (prod webhook endpoint)
  • PAYMENT_CREDENTIALS_ENCRYPTION_KEY, NATIONAL_ID_ENCRYPTION_KEY — see credentials-rotation.md
  • INVITE_SECRET, QR_CHECKIN_SECRET
  • CARDCOM_* real terminal, PLATFORM_BILLING_* Taikan’s own Cardcom credentials, MORNING_* prod hosts
  • R2_* prod bucket credentials, R2_COMPLIANCE_BUCKET_NAME (object-locked)
  • VERCEL_TOKEN, VERCEL_MINISITES_PROJECT_ID — minisite domain provisioning
  • SENTRY_DSN, SENTRY_AUTH_TOKEN (build-time, for source-map upload). The release on API events is the deployed commit SHA, matching what sentry-release.yml registers: the Sentry SDK reads SENTRY_RELEASE and falls back to Render’s RENDER_GIT_COMMIT, so nothing needs setting on Render.
  • POSTHOG_API_KEY, POSTHOG_PROJECT_ID — server-side event ingestion + admin observability
  • CRONS_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-testid that’s referenced in apps/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.