Render standby and cutover
A second home for the API, ready to take production from Railway in about 30 minutes of operator time and ~5–10 minutes of downtime, and a tested way back.
Status: the cutover has been executed — production runs on Render (api.taikan.fit → taikan-api.onrender.com; the Railway production services hold no deployment). The “Standby state” column below describes the pre-cutover standby; deploy.md is the current topology. The Revert section applies only while Railway is kept intact.
Why it exists: on 2026-10-21/22 Railway paused every build and deploy for about 30 hours (security incident, see the disclosure ). The running API never went down, but no fix could ship. The standby removes that single point of failure for deploys.
All commands below are scripts/render-cutover/cutover.sh <phase>. It prints no secret values: credentials are fetched at run time from Render (CLI login, ~/.render/cli.yaml) and Railway (railway login), and never written to disk except the rotated Railway Postgres password in ~/.taikan-cutover/ (chmod 600), which rollback needs.
What exists on Render
Workspace Taikan, region Frankfurt.
| Resource | ID | Plan | Standby state |
|---|---|---|---|
API web service taikan-api | srv-dbcpbsi9lscs73em0150 | Standard ×2 instances | suspended, auto-deploy off |
Postgres taikan-db (PG 18) | dpg-dbcpb7ei0phs73fjdrr0-a | 1c-2g | suspended; holds a stale copy, overwritten at cutover |
Key Value taikan-redis | created by wake | 1g, noeviction | deleted (Render can neither suspend nor downgrade it; it holds only queue state, which is copied from Railway at cutover) |
| Custom domain | api.taikan.fit | — | registered, unverified until DNS points at Render |
- Build:
apps/api/Dockerfile, context = repo root, runs as the unprivilegednodeuser. Pre-deploy commandpnpm db:migrate; health check/health/live. - The Dockerfile must never sit at the repo root. Railway auto-detects a root Dockerfile despite
builder = "RAILPACK"inapps/api/railway.toml, and its builder rejects the BuildKit secret mount, which failed every Railway deploy until it moved (#624). - Key Value needs the 1g plan. The API holds ~100 Redis connections per instance (one BullMQ connection set per queue); a rolling deploy briefly runs 4 instances. The 256mb plan caps at 250 connections and failed the rollout.
- Env: a copy of Railway’s
@taikan/apivariables, with Render’s ownDATABASE_URL/DIRECT_DATABASE_URL/REDIS_URL, andCRONS_ENABLED/AUTOMATIONS_ENABLED/FIZIKAL_ENABLEDoff until cutover. Railway stays the source of truth: runsync-envon every wake-up, because keys rotated on Railway since the copy are stale on Render. - External DB/Key Value access is closed (empty IP allowlist).
allow-meopens it to the operator’s current IP; the IP changes often (mobile hotspot, home), so re-run it beforerestore. - Deploy times measured: Render 3–5 min from merge to live. Railway during the incident: 17 min to 1.5 h, two builds failed on Railway’s side.
Wake up (standby → ready, no production impact)
allow-mewake— creates the Key Value (1g, noeviction), resumes Postgres and the API, turns auto-deploy back on. Runallow-meagain afterwards so the new Key Value accepts your IP.sync-env, thenrender deploys create srv-dbcpbsi9lscs73em0150 --confirm.preflight— row counts on both sides, Render health, Redis key counts.- Optional rehearsal with fresh data: take a read-only dump of Railway and run
restoreagainst it (set~/.taikan-cutover/final-dump-pathto the dump), then check/health/readyonhttps://taikan-api.onrender.com. - Optional canary: route one Clerk user to Render through the Cloudflare Worker in
scripts/render-cutover/canary-worker/(wrangler deploy --route "api.taikan.fit/*"). Every write that user makes lands in Render’s copy and is lost at cutover; payments and messages are real. Remove the route to end it.
Cutover
Pick a quiet window. PostHog traffic by Israel hour (launch week): 02:00–05:00 lowest; 23:00 is the latest hour an operator is usually awake. Avoid 04:00–06:15 Israel time (daily charge run at 02:00 UTC and the daily jobs after it), and never cut over in the days right before a large charge run. Announce a ~10 minute window to gym owners. Do not merge PRs with migrations until the new host has been stable for a week.
| # | Step | Command | Time |
|---|---|---|---|
| 0 | Wake up + sync-env + deploy (above), allow-me again | 10 min, before the window | |
| 1 | Render API off so nothing holds its DB | suspend | seconds |
| 2 | Downtime starts. Rotate Railway’s Postgres password and kill connections: no more writes or crons on Railway. Run at a minute that is not a multiple of 5 | freeze | seconds |
| 3 | Cloudflare: api → CNAME taikan-api.onrender.com, DNS only, TTL 60 | dashboard / API | 1 min |
| 4 | Final dump (retries on a dropped proxy connection) + row counts | dump | ~2 min |
| 5 | Restore into Render; stops if row counts differ; checks migrations | restore | ~2.5 min |
| 6 | Copy Redis (queues, delayed jobs) | redis | seconds |
| 7 | Resume Render API, wait for /health/ready | resume | ~2 min |
| 8 | Certificate + smoke: sign-in, a booking, a ₪1 charge and refund. Downtime ends | domain-status | ~3 min |
| 9 | Crons and automations on (deploy) | crons-on | ~5 min |
After it is stable: Cloudflare back to proxied with SSL mode Full; update docs/runbooks/deploy.md, incident-response.md, monitoring.md, AGENTS.md’s Railway references.
Revert
Before real users write to Render: rollback (restores Railway’s original Postgres password; the Railway API reconnects by itself, crons resume) and point Cloudflare api back to the Railway domain, proxied. 2–5 minutes, nothing lost.
After real writes on Render, at any time while Railway is kept intact: revert-suspend → revert-dump → revert-restore → revert-redis → rollback → Cloudflare back to Railway. ~10 minutes of downtime, nothing lost. None of these steps needs Railway’s deploy pipeline.
Keep every Railway service untouched for 1–2 weeks after a cutover.
Back to standby
standby — crons off, auto-deploy off, API and Postgres suspended, external access closed, Key Value deleted.
Cost
Measured on Render’s billing page: about $3.7/day ≈ $110/month with two Standard instances, Postgres 1c-2g and Key Value 1g (Railway Pro was ~$20/month). One instance is not acceptable for production resilience. Possible reductions: smaller Postgres (the DB is ~110 MB), fewer Redis connections per instance (would allow a smaller Key Value), and Render’s migration credits. On standby: suspended services do not bill compute, and the Key Value is deleted.
Security notes from the Railway incident
- Railway variables include the production DB password and public URL, Redis, the
pg-s3-backupsbucket keys (every historical dump), and every API key. The cutover’sfreezerotates the DB password; everything else follows credentials-rotation.md. - Cardcom v11 authenticates charges with TerminalNumber + ApiName (+ token); the API password is only needed for refunds and cancellations. The portal’s “IP מורשה” list did not block ApiName calls in testing, does not honor CIDR ranges, and applied to “all users” it locks humans out of the portal. Protection that works: refunds limited to the original deal (no free-standing credit), a new API key (new ApiName) when rotating, and
scripts/ops/cardcom-deal-watch.cjs, which flags any Cardcom deal Taikan never recorded.