Skip to content

Staging Environment (Cloudflare)

Staging runs on showfm.dev (Epic 16 GATE-9, 2026-08-24 — staging moved off podcasterplus.dev in one sweep so it mirrors the ENTIRE post-flip surface map; the old zone is parked and its reappearance in any staging section fails CI). Staging label map: my. (app), book., admin., rss. (feeds), m. (media), embed., api., mcp., and {slug}.showfm.dev customer pages on the wildcard (listen runs in HOST mode — listen. is a reserved label and serves nothing; staging deliberately does NOT mirror the two-domain brand/structural split, one zone carries every surface). Previous zone d99918e0df83c0a4c2168b8a51a668ac (same account b8eff1b484adf398bda38644efc85bce) as a full parallel of production: every production worker except dr-replicator carries an [env.staging] block in its wrangler.toml, deployed with wrangler deploy --env staging as <name>-staging. The staging database is the Supabase persistent branch (see Staging Database); this page covers everything Cloudflare-side.

Program spine: docs/planning/plans/2026-07-27-environments-and-delivery-pipeline.md (W5).

What exists

ResourceStaging name / id
Workers<production-name>-staging × 16 (15 workers + podcasterplus-app-staging)
Queuesrss-invalidation-staging, public-api-invalidation-staging, automation-events-staging, automation-executions-staging, podcast-imports-staging, external-episode-link-staging, media-jobs-staging, ai-jobs-staging, notification-deliveries-staging
Dead-letter queuesautomation-dlq-staging, podcast-imports-dlq-staging, external-episode-link-dlq-staging, media-jobs-dlq-staging, ai-jobs-dlq-staging, notification-deliveries-dlq-staging
KVpodcasterplus-rss-cache-staging b3a559f339dc4fd0878f9723e2f05d22, podcasterplus-public-api-cache-staging 24c69668994d46aa892edafdb3160713
R2podcasterplus-media-staging (CORS: PUT from https://my.showfm.dev + localhost (re-point at GATE-9 cutover)), podcasterplus-feeds-stagingdeliberately NO event notifications (nothing replicates staging to B2; the DR plane is production-only)
Hyperdrivepodcasterplus-supabase-staging 50a8883c080f46a1b6d50705622f90d3 (staging branch origin, limit 20)
WAE datasetsmedia_requests_staging, feed_requests_staging, api_requests_staging (auto-created on first write; analytics-rollup-staging reads them via the WAE_MEDIA_DATASET / WAE_FEED_DATASET vars)
Hostnamesmy. book. admin. + {slug} wildcard (app worker), rss. (rss-feed), m. (media-delivery), api. embed. (public-api), mcp. (mcp) — all *.showfm.dev

Excluded on purpose: dr-replicator (DR replication must never run against staging), r2-object-events(-dlq) (its transport), and the two POC workers.

The rules the configs encode

  • Every binding and var is restated in [env.staging] — bindings are non-inheritable. Nothing in a staging block may name a production resource, and nothing outside one may name a staging resource. Both directions are asserted by src/lib/egress/__tests__/wrangler-staging-isolation.test.ts, which also pins the closed set of 16 configs.
  • triggers and routes ARE inheritable — the two footguns. Every cron worker's staging block sets crons = [] (C4: staging crons off), and every route-bearing worker restates staging routes; omitting either would run the production schedule or claim a production hostname from the staging deploy.
  • Route patterns, never custom_domain = true — on staging as in production (.claude/rules/backend/workers.md): managed DNS records are uneditable, route patterns keep every staging DNS record a plain dashboard row.
  • ENVIRONMENT = "staging" is restated exactly where production declares it (the six egress-affected workers + the app). Staging egress: email/webhooks allowlist-gated (EGRESS_EMAIL_ALLOWLIST unset ⇒ no email leaves staging), Podping / Podcast Index / calendar writes / push suppressed.

Deploying staging

Since W6, merges to main deploy staging automatically — affected surfaces only, detected by scripts/affected-workers.mjs; see Deploy Pipeline. The commands below remain the manual / break-glass path (they do not move the pipeline's deployed/staging tag, so the next pipeline run may harmlessly redeploy the same worker).

Order matters once, at first creation: the app's [[services]] bindings target podcasterplus-rss-feed-staging and podcasterplus-public-api-staging, so those two deploy first.

bash
# 1. Route-bearing workers (need the DNS records below to serve, deploy fine without)
cd workers/rss-feed        && CLOUDFLARE_ACCOUNT_ID=b8eff1b484adf398bda38644efc85bce npx wrangler deploy --env staging
cd workers/public-api      && CLOUDFLARE_ACCOUNT_ID=b8eff1b484adf398bda38644efc85bce pnpm run deploy:staging
cd workers/media-delivery  && CLOUDFLARE_ACCOUNT_ID=b8eff1b484adf398bda38644efc85bce npx wrangler deploy --env staging

# 2. The app
pnpm run deploy:staging

# 3. Everything else (media-processor builds the ffmpeg container — Docker must be running)
for w in analytics-rollup automation-executor automation-scheduler crm-sync \
         distribution-monitor lifecycle-manager media-processor ai-processor \
         notification-executor notification-scheduler podcast-import-executor \
         scheduled-publisher; do
  (cd workers/$w && CLOUDFLARE_ACCOUNT_ID=b8eff1b484adf398bda38644efc85bce npx wrangler deploy --env staging)
done

Secrets are per-environment and do not exist until set: wrangler secret put <NAME> --env staging (or wrangler secret bulk). Each worker's staging banner in its wrangler.toml names exactly the secrets it needs; the app worker's full inventory is the sweep in docs/planning/plans/2026-07-29-pages-to-workers-migration.md step 7, with staging values substituted (staging Supabase secret key, staging Stripe sandbox keys, staging Access AUD, an R2 token scoped to podcasterplus-media-staging, fresh random values for the internal bearer secrets).

Cron lanes are manual in staging (C4)

All staging crons are disabled. Every cron worker exposes a secret-gated manual trigger on its workers.dev URL (https://<name>-staging.<team>.workers.dev):

WorkerTriggerAuth bearer
analytics-rollupPOST /__runRUN_TRIGGER_SECRET
distribution-monitorPOST /__runRUN_TRIGGER_SECRET
crm-syncPOST /__run (nightly) / POST /__run?job=digestRUN_TRIGGER_SECRET
lifecycle-managerPOST /__run (minute lane) / POST /__run?lane=dailyRUN_TRIGGER_SECRET
automation-schedulerPOST /_trigger/timeSUPABASE_SECRET_KEY
notification-schedulerPOST /_triggerSUPABASE_SECRET_KEY
scheduled-publisherPOST /_triggerRSS_INVALIDATION_SECRET

(The last three predate the __run convention and keep their existing shapes; the crm-sync and lifecycle-manager triggers were added at W5 following distribution-monitor's pattern, reusing the real scheduled dispatch so a manual run exercises exactly what the cron runs.)

Notification emails do not send on staging until you fire the trigger

notification-scheduler is what moves a notification_deliveries row from pending into the delivery queue. Its cron is off here like every other, so on staging every notification sits pending forever and nothing reaches Resend until someone POSTs /_trigger.

This one is worth calling out separately because it presents as a product regression, not as a missing cron (diagnosed 2026-08-06, after a manual E2E pass reported "the host never gets their booking email"):

  • The in-app notification still arrives, because the UI reads the notifications row directly and does not wait on a delivery.
  • Guest emails still arrive, because booking confirmations, reminders and invitations are DIRECT transactional sends (src/lib/email/), which bypass the notification platform entirely.
  • Only the host's email is missing, because that one is a platform notification (booking.requested, emailRenderer: 'host_notification').
  • Resend shows no attempt at all, so the logs look like the send was never coded rather than never dispatched, and the natural next move — widening EGRESS_EMAIL_ALLOWLIST — changes nothing.

Before concluding anything about notification email on staging, check for stuck deliveries first:

sql
SELECT status, channel, attempts, count(*)
FROM notification_deliveries
GROUP BY 1, 2, 3
ORDER BY 4 DESC;

pending with attempts = 0 means the scheduler has not run. pending with attempts > 0 is a different fault: those were claimed and delivery itself is failing, so read error_message before blaming the cron.

Fire the trigger, re-run the query, and repeat until the backlog is empty. One run is not necessarily enough. The scheduler claims at most SCHEDULER_MAX_RECORDS_PER_RUN deliveries per invocation (default 200, workers/notification-scheduler/src/index.ts) and the claim loop stops at that ceiling (src/lib/notifications/delivery.ts). Production never shows this because the cron fires every minute; here, one manual POST leaves anything beyond 200 still pending, which looks exactly like the trigger having no effect.

Everything a run does claim is really sent, so expect a burst of genuine email.

Also: EGRESS_EMAIL_ALLOWLIST is declared in each worker's wrangler.toml[env.staging.vars], so a dashboard edit to it is overwritten by the next deploy. Change it in the file.

One-time manual steps — ALL EXECUTED 2026-07-30/31 (Dan + Claude)

Recorded as-built because every item below lives in a dashboard, not in git — a staging re-clone or zone recreation must re-do the relevant rows (see the re-clone checklist in Staging Database, which now points here for the Cloudflare/Stripe/Google half).

  1. DNS — proxied AAAA <host> 100:: records on the showfm.dev zone (8 explicit hosts + the * wildcard; the gate9 DNS script creates them) (app, book, admin, listen, feed, media, api, embed). The wrangler OAuth token has no DNS scope; dashboard-only.
  2. No zone-wide Access, by decision. Whole-domain Access would break every inbound webhook (Resend, Stripe, FreeScout, MooSend) and podcast-app feed fetches — exactly what staging rehearses. Instead: staging mirrors production's perimeter (Access on admin. only), plus a zone-level Response Header Transform Rule setting X-Robots-Tag: noindex, nofollow on every response (verified live). Residual exposure = the same surfaces production serves publicly, backed by the scrubbed clone with rotated tokens.
  3. Cloudflare Access application — a DEDICATED app for admin.showfm.dev with its own AUD (re-created at GATE-9; the old admin.podcasterplus.dev app is retired) (it briefly shared the production application; removed — a shared AUD makes production admin JWTs valid on staging and couples policy changes across environments).
  4. Supabase branch auth config (project ncjbvoyalhkgqdvzltqh): site URL https://my.showfm.dev; redirect allowlist https://my.showfm.dev/** + https://admin.showfm.dev/**; custom SMTP via Resend (dedicated API key, sender PodcasterPlus Staging <[email protected]> so any stray delivery self-identifies; rate limit left at default). GoTrue auth emails bypass the W3 egress policy entirely, so these Supabase-side settings ARE the control. Mirrored session/auth settings: session time-box 8760h, inactivity timeout 720h, leaked-password protection ON, email OTP expiry 900s / length 6. Password requirements are unset in BOTH environments — issue #285.
  5. Google OAuth client (staging) — second client in the production Google Cloud project, single redirect URI https://my.showfm.dev/api/auth/google/callback. EGRESS_ALLOW = "calendar_write" is OPEN in the app's staging vars (Dan, 2026-07-31): staging bookings write real events to calendars connected in staging. push stays suppressed (no staging VAPID pair). Consequence: a Web Push change cannot be verified on staging at all — the executor never reaches a push service there, so a broken send path looks identical to a working one. Verify push in production after promotion, on a real browser. Never diagnose a staging push non-delivery as a code bug.
  6. Stripe staging sandbox — catalog replicated from production test mode by scripts/stripe-replicate-catalog.mjs (all 12 lookup*keys; the app resolves prices by lookup_key, so staging needs no STRIPE*_*PRICE_IDsecrets). Webhook endpointhttps://my.showfm.dev/api/webhooks/stripe created via the API pinned to 2025-05-28.basil — matching the PRODUCTION endpoint's version, because webhook payload shapes follow the endpoint, and basil is what the handler has been empirically validated against (the code's 2025-12-15.clover pin governs outbound calls only; the divergence is resolved deliberately at W8). Customer-portal default config saved (a fresh sandbox has none and the portal route 400s without it). Publishable key rides [env.staging.vars]. The legacy PUBLIC_STRIPE*_\_LINKpayment-link vars are read nowhere insrc/ and exist only in production's vars as leftovers.
  7. Secrets — 44 across the 16 workers, set with wrangler secret put <NAME> --env staging per worker and verified by a names-only list against the matrix below. Internal bearer secrets (RSS_INVALIDATION_SECRET, API_INVALIDATION_SECRET, IP_HASH_SECRET, SWEEP_SECRET, RECONCILER_SECRET) are fresh staging-only randoms; one shared staging RUN_TRIGGER_SECRET covers the four __run workers (Dan holds it); the R2 pair is a token scoped to podcasterplus-media-staging only; Resend uses a dedicated podcasterplus-staging-app key; CF_ANALYTICS_API_TOKEN is a freshly minted Account Analytics Read token (production secrets are write-only — the old value was never recoverable, and doesn't need to be).
  8. EGRESS_EMAIL_ALLOWLIST (Dan, 2026-07-31), set in the staging vars of the app + automation-executor, crm-sync, lifecycle-manager, notification-executor, podcast-import-executor: danmaby.com,myprivate.email,nathanwrigley.com,[email protected],[email protected] (bare domain = domain + subdomains; @ entry = exact address).

Per-worker staging secret matrix (verified names-only 2026-07-31)

Regenerate/verify any row (names only, never values) with npx wrangler secret list --env staging in the worker's directory (repo root for the app); the row below must match exactly — nothing missing, nothing extra.

WorkerSecrets
appAPI_INVALIDATION_SECRET, CF_ACCESS_AUD, CF_ACCESS_TEAM_DOMAIN, CF_TURNSTILE_KEY, EMAIL_SYNC_INTERNAL_SECRET, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, MCP_SHARED_SECRET, RESEND_API_KEY, RESEND_AUDIENCE_ID, RESEND_WEBHOOK_SECRET, RSS_INVALIDATION_SECRET, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, SUPABASE_SECRET_KEY (re-verified names-only 2026-09-06: the R2 pair is not set on staging, and EMAIL_SYNC_INTERNAL_SECRET, MCP_SHARED_SECRET and RESEND_AUDIENCE_ID had been added since 2026-07-31)
rss-feedIP_HASH_SECRET, RSS_INVALIDATION_SECRET, SUPABASE_SECRET_KEY
media-deliveryIP_HASH_SECRET
public-apiAPI_INVALIDATION_SECRET
analytics-rollupCF_ANALYTICS_API_TOKEN, RUN_TRIGGER_SECRET
automation-executorRESEND_API_KEY, RESEND_FROM_EMAIL, SUPABASE_SECRET_KEY
automation-schedulerSUPABASE_SECRET_KEY
crm-syncRUN_TRIGGER_SECRET
distribution-monitorRUN_TRIGGER_SECRET
lifecycle-managerRECONCILER_SECRET, RESEND_API_KEY, RESEND_FROM_EMAIL, RSS_INVALIDATION_SECRET, RUN_TRIGGER_SECRET, STRIPE_SECRET_KEY, SUPABASE_SECRET_KEY
media-processorR2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, SWEEP_SECRET
ai-processor(none)
notification-executorRESEND_API_KEY, RESEND_FROM_EMAIL
notification-schedulerSUPABASE_SECRET_KEY
podcast-import-executorRESEND_API_KEY, RESEND_FROM_EMAIL
scheduled-publisherRSS_INVALIDATION_SECRET, SUPABASE_SECRET_KEY

CF_TURNSTILE_KEY (added 2026-09-06) holds Cloudflare's always-pass test site key 1x00000000000000000000AA, paired with the always-pass test secret in the staging branch's Supabase auth settings, so every auth form runs the real token path and still passes under headless Playwright. The pairing rules and the production values are in Bot protection.

Deliberately NOT set in staging

Secret(s)Why
FREESCOUT_* (6)support_write is egress-suppressed; staging must not mirror or write to the real helpdesk
MOOSEND_WEBHOOK_TOKEN, MOOSEND_API_KEYno staging MooSend wiring; crm-sync's MooSend stage logs a skip
VAPID pair + subjectWeb Push suppressed pending an EGRESS_ALLOW=push decision with a staging VAPID pair
RESENDAUDIENCE_ID, RESEND_TOPIC*staging must never write into the real marketing audience
SUPPORTCONTEXT*, SUPPORT_PUBLIC_API_SECRETsupport-context APIs serve the production www site only
PODCASTINDEX**, SPOTIFY**distribution detection skips per-platform without them (read-only if ever added)
PODPING_TOKENdirectory pings suppressed in staging regardless

Verified live (2026-07-31)

All 16 workers deployed (<name>-staging); the eight .dev hostnames route to staging workers while the production zone's route table is verified unchanged; rss.showfm.dev/<real-slug> returns a full RSS feed from the staging branch DB over the staging Hyperdrive; /v1/podcasts/<slug> returns staging JSON; the app serves and 303s to login; every response carries x-robots-tag: noindex, nofollow.

Known limitation (deliberate, decision pending)

Some staging surfaces still emit production URLs in generated content — embed snippets, the player component's default API base, booking-link copy-to-clipboard strings, notification/automation email link templates with hardcoded defaults, distribution platform feed/listen URL helpers (src/lib/constants/distribution-platforms.ts), admin deep links in Stripe webhook handling, image-transform domains (src/lib/utils/image-url.ts), the docs-search origin and the FreeScout base URL. None of these stop staging serving (the server-side environment separation is complete and tested); they make some rehearsal artifacts point at production. Making URL emission fully environment-aware requires threading env into worker-bundled shared modules (tag-parser, distribution-platforms) and is a separable sweep — tracked as an open item in the program plan (W5 as-built).

Internal documentation - Not for public distribution