Staging to prod cutover

Why

Everything is on Railway staging while it’s actively developed. When we stand up a prod environment with real customers, we copy the env but swap a set of things. The risk is missing one (Stripe test keys in prod, or staging sharing infra that lets it clobber prod data).

R2: separate buckets per environment

Decision: prod gets its own R2 bucket, not the shared staging one.

Reasoning is blast radius, not storage. Staging churns hard (test uploads, product deletions, re-imports). A shared bucket means a staging cleanup or migration can overwrite or delete an object prod is serving. Medusa’s file module deletes the backing R2 object when a product/image is removed, so “delete all products” on staging would nuke live covers if shared. Covers being byte-identical across envs (ISBN-keyed) is a real but weak argument to share; covers are cheap to reproduce by re-running the scraper, not worth the risk.

So prod = its own bucket + its own credentials + its own public domain. Staging keeps assets.dungeonbooks.com or we flip it: prod takes assets.dungeonbooks.com, staging moves to a staging domain or the *.r2.dev URL. Decide which domain prod owns at cutover.

Env diff checklist

Per service, what changes from staging to prod:

Stripe (highest risk if missed)

  • STRIPE_API_KEY test (sk_test_) to live (sk_live_)
  • STRIPE_WEBHOOK_SECRET from the prod webhook endpoint

Databases + cache (separate instances)

  • Medusa DATABASE_URL
  • REDIS_URL
  • Payload PAYLOAD_DATABASE_URL
  • catalog CATALOG_DB_URL (prod imports from its own queue so staging re-scrapes don’t flip prod sync state)

R2 (separate bucket, see above)

  • backend S3_* (endpoint, bucket, keys, S3_FILE_URL)
  • payload S3_*
  • scraper R2_* (endpoint, keys, R2_BUCKET_NAME, R2_PUBLIC_URL)

Secrets (generate fresh)

  • JWT_SECRET, COOKIE_SECRET (backend)
  • PAYLOAD_SECRET

URLs + CORS

  • backend MEDUSA_BACKEND_URL, STORE_CORS, ADMIN_CORS, AUTH_CORS
  • storefront NEXT_PUBLIC_MEDUSA_BACKEND_URL, NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY (prod channel’s key), PAYLOAD_URL
  • payload PAYLOAD_PUBLIC_SERVER_URL
  • MEILISEARCH_HOST / MEILISEARCH_API_KEY (separate instance/index so staging reindexes don’t touch prod search)

Notes

  • The catalog scraper’s IPAGE_COOKIE is the same Ingram account regardless of env; not an env-diff item.
  • Migrations must run against each prod DB before first boot (Medusa + Payload).
  • See also: the per-service Dockerfile / minimal-build work (separate backlog) makes prod deploys faster and is worth doing around the same time.