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
| Resource | Staging name / id |
|---|---|
| Workers | <production-name>-staging × 16 (15 workers + podcasterplus-app-staging) |
| Queues | rss-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 queues | automation-dlq-staging, podcast-imports-dlq-staging, external-episode-link-dlq-staging, media-jobs-dlq-staging, ai-jobs-dlq-staging, notification-deliveries-dlq-staging |
| KV | podcasterplus-rss-cache-staging b3a559f339dc4fd0878f9723e2f05d22, podcasterplus-public-api-cache-staging 24c69668994d46aa892edafdb3160713 |
| R2 | podcasterplus-media-staging (CORS: PUT from https://my.showfm.dev + localhost (re-point at GATE-9 cutover)), podcasterplus-feeds-staging — deliberately NO event notifications (nothing replicates staging to B2; the DR plane is production-only) |
| Hyperdrive | podcasterplus-supabase-staging 50a8883c080f46a1b6d50705622f90d3 (staging branch origin, limit 20) |
| WAE datasets | media_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) |
| Hostnames | my. 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 bysrc/lib/egress/__tests__/wrangler-staging-isolation.test.ts, which also pins the closed set of 16 configs. triggersandroutesARE inheritable — the two footguns. Every cron worker's staging block setscrons = [](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_ALLOWLISTunset ⇒ 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.
# 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)
doneSecrets 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):
| Worker | Trigger | Auth bearer |
|---|---|---|
| analytics-rollup | POST /__run | RUN_TRIGGER_SECRET |
| distribution-monitor | POST /__run | RUN_TRIGGER_SECRET |
| crm-sync | POST /__run (nightly) / POST /__run?job=digest | RUN_TRIGGER_SECRET |
| lifecycle-manager | POST /__run (minute lane) / POST /__run?lane=daily | RUN_TRIGGER_SECRET |
| automation-scheduler | POST /_trigger/time | SUPABASE_SECRET_KEY |
| notification-scheduler | POST /_trigger | SUPABASE_SECRET_KEY |
| scheduled-publisher | POST /_trigger | RSS_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
notificationsrow 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:
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).
- DNS — proxied
AAAA <host> 100::records on theshowfm.devzone (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. - 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 settingX-Robots-Tag: noindex, nofollowon every response (verified live). Residual exposure = the same surfaces production serves publicly, backed by the scrubbed clone with rotated tokens. - Cloudflare Access application — a DEDICATED app for
admin.showfm.devwith 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). - Supabase branch auth config (project
ncjbvoyalhkgqdvzltqh): site URLhttps://my.showfm.dev; redirect allowlisthttps://my.showfm.dev/**+https://admin.showfm.dev/**; custom SMTP via Resend (dedicated API key, senderPodcasterPlus 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. - 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.pushstays 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. - 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 noSTRIPE*_*PRICE_IDsecrets). Webhook endpointhttps://my.showfm.dev/api/webhooks/stripecreated via the API pinned to2025-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's2025-12-15.cloverpin 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 legacyPUBLIC_STRIPE*_\_LINKpayment-link vars are read nowhere insrc/and exist only in production's vars as leftovers. - Secrets — 44 across the 16 workers, set with
wrangler secret put <NAME> --env stagingper 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 stagingRUN_TRIGGER_SECRETcovers the four__runworkers (Dan holds it); the R2 pair is a token scoped topodcasterplus-media-stagingonly; Resend uses a dedicatedpodcasterplus-staging-appkey;CF_ANALYTICS_API_TOKENis a freshly minted Account Analytics Read token (production secrets are write-only — the old value was never recoverable, and doesn't need to be). 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.
| Worker | Secrets |
|---|---|
| app | API_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-feed | IP_HASH_SECRET, RSS_INVALIDATION_SECRET, SUPABASE_SECRET_KEY |
| media-delivery | IP_HASH_SECRET |
| public-api | API_INVALIDATION_SECRET |
| analytics-rollup | CF_ANALYTICS_API_TOKEN, RUN_TRIGGER_SECRET |
| automation-executor | RESEND_API_KEY, RESEND_FROM_EMAIL, SUPABASE_SECRET_KEY |
| automation-scheduler | SUPABASE_SECRET_KEY |
| crm-sync | RUN_TRIGGER_SECRET |
| distribution-monitor | RUN_TRIGGER_SECRET |
| lifecycle-manager | RECONCILER_SECRET, RESEND_API_KEY, RESEND_FROM_EMAIL, RSS_INVALIDATION_SECRET, RUN_TRIGGER_SECRET, STRIPE_SECRET_KEY, SUPABASE_SECRET_KEY |
| media-processor | R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, SWEEP_SECRET |
| ai-processor | (none) |
| notification-executor | RESEND_API_KEY, RESEND_FROM_EMAIL |
| notification-scheduler | SUPABASE_SECRET_KEY |
| podcast-import-executor | RESEND_API_KEY, RESEND_FROM_EMAIL |
| scheduled-publisher | RSS_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_KEY | no staging MooSend wiring; crm-sync's MooSend stage logs a skip |
| VAPID pair + subject | Web 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_SECRET | support-context APIs serve the production www site only |
| PODCASTINDEX**, SPOTIFY** | distribution detection skips per-platform without them (read-only if ever added) |
| PODPING_TOKEN | directory 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).