Skip to main content

CalGest Launch-Day Operational Checklist

Generated 2026-07-23 from a code-verified production-readiness audit. Source of truth for each item is cited with file:line. Ordered by when it must happen. “Convex” = production Convex deployment env (npx convex env set / dashboard). Frontend VITE_/EXPO_PUBLIC_ vars are build-time and bake into the bundle, so they must be correct before each app is built.
Status note (verified 2026-09-03 against live Convex, GitHub, EAS, and the public hosts). Local gates pass on the current branch: typecheck, lint, and 1288 vitest tests. The blockers are all environment and release plumbing.
  1. Prod Convex runs a Stripe test key. STRIPE_SECRET_KEY on sleek-chipmunk-87 starts with sk_test_. The six price IDs and the coupon are set and none end in _TODO, but a test key only sees test-mode prices, so live checkout cannot work until the key and the prices are recreated in live mode and the webhook secret is regenerated for the live endpoint. Nothing in the deploy gate catches this.
  2. Prod Convex still lacks SENTRY_DSN and SENTRY_ENVIRONMENT (30 env vars, same set as in August). Both the pre-deploy gate in deploy.yml and productionConfig:validate require them, so a backend deploy fails before it pushes code.
  3. Prod is running stale code. GET /healthz and GET /healthz/ready on the prod site both return 404. The last automated deploy (2026-07-10) failed schema validation because an organizationMemberships row has no userId, so migrations:runBackfillOrganizationMembershipUserId must run against prod before the next deploy can succeed.
  4. Every GitHub repository variable is unset, including SENTRY_ORG and SENTRY_PROJECT. The dashboard and web deploys fail at validate:env.
  5. The EXPO_TOKEN repo secret does not exist. The mobile OTA deploy and the Mobile Build workflow have never run. The only EAS build on record is a preview iOS build from 2026-05-09.
  6. https://cdn.calgest.com returns 404. The CDN worker has not been deployed to production. dashboard.calgest.com and calgest.com both serve.
  7. The iOS permission blocker from July is unfixed. app.json declares microphone and speech recognition only, and apps/mobile/ios is gitignored, so EAS builds get no NSCameraUsageDescription or NSPhotoLibraryUsageDescription. Android declares no camera permission. Profile photo capture (settings/profile.tsx uses expo-image-picker) will crash on iOS.
Housekeeping: Release Please PR #32 has been open since July with its checks stuck at action_required, so no release tag covers the hardening work. VITE_POSTHOG_KEY and the prod POSTHOG_API_KEY/POSTHOG_HOST vars are still set and still unused. Verified correct: all 12 expected repo secrets exist, the EAS production environment has its 9 required variables, CLERK_JWT_ISSUER_DOMAIN is https://clerk.calgest.com, ALLOWED_REDIRECT_DOMAINS covers the dashboard and marketing hosts, CDN_URL is https://cdn.calgest.com, and the web app ships privacy and terms pages in PT and EN.

BEFORE DEPLOY

1. External services provisioned

  • Stripe (managed_payments / alpha) — create the 6 recurring prices + the annual coupon; their IDs feed the env vars below. packages/backend/convex/plans.ts:283-296 lists the exact keys. assertBillingConfig() (plans.ts:234) hard-fails checkout if any is unset or still ends in _TODO, so placeholders block paid signups.
    • Coupon must be percent_off: 20, duration: foreverSTRIPE_COUPON_ANNUAL (plans.ts:68, comment at :60-66).
  • Clerk — production instance with a JWT template named exactly convex (hardcoded applicationID: "convex" in auth.config.js:11). Issuer domain → CLERK_JWT_ISSUER_DOMAIN.
  • Clerk → custom email delivery via webhook: the app renders/sends Clerk transactional emails itself through the email.created webhook event (http.ts:687-707, dispatches to clerkEmailActions.handleEmailCreated). Clerk must be configured to emit email.created (i.e. “send emails via webhook” / integration enabled), otherwise verification/reset emails silently never send.
  • Resend — verify the sending domain; the from-address is required in prod (notificationEmail.ts:16-27 and clerkEmailActions.ts:14-21 throw if RESEND_FROM_EMAIL unset outside an explicit dev env).
  • Cloudflare R2 — two EU-jurisdiction buckets already referenced: calgest-prod (and calgest-dev) in apps/cdn/wrangler.jsonc:12-16. Generate an R2 API token (S3 creds + token).
  • GatewayAPI (SMS) — account + sender token; choose a webhook auth token for delivery callbacks.
  • OpenAI — API key for the AI assistant agent (agent.ts:1,85-86).
  • Sentry — DSN + auth token/org/project for error reporting and source-map upload. The production backend gate requires the DSN.
    • Keep the project on the free tier and create its single uptime monitor for GET https://<convex-site-url>/healthz/ready.
    • Configure an email alert for new/high-volume errors; do not enable tracing, replay, logs, profiling, or default PII.

2. Production Convex deployment env vars

Read locations in parentheses. All required unless marked optional. Auth / Clerk
  • CLERK_JWT_ISSUER_DOMAIN (auth.config.js:10)
  • CLERK_SECRET_KEY — backend Clerk API for Google-calendar OAuth tokens & user locale (calendarImport.ts:27, userLocale.ts:26)
  • CLERK_WEBHOOK_SECRET — svix verification (http.ts:602)
Stripe (STRIPE_SECRET_KEY read in http.ts:265 and by @convex-dev/stripe; STRIPE_WEBHOOK_SECRET by the component’s registerRoutes)
  • STRIPE_SECRET_KEY — must be a live key (sk_live_). As of 2026-09-03 prod holds an sk_test_ key. Price and coupon IDs are mode-specific, so recreate all seven in live mode at the same time.
  • STRIPE_WEBHOOK_SECRET
  • STRIPE_PRICE_SOLO_MONTHLY, STRIPE_PRICE_SOLO_YEARLY
  • STRIPE_PRICE_TEAM_MONTHLY, STRIPE_PRICE_TEAM_YEARLY
  • STRIPE_PRICE_TEAM_SEAT_MONTHLY, STRIPE_PRICE_TEAM_SEAT_YEARLY
  • STRIPE_COUPON_ANNUAL
  • ALLOWED_REDIRECT_DOMAINS — comma-separated host allowlist. Fails closed: stripe.ts:77-85 rejects every non-localhost checkout success/cancel redirect when empty, so an unset value silently breaks checkout in production. Must contain the dashboard host (e.g. dashboard.calgest.com).
R2 / attachments / CDN (internalAttachments.ts:19-22, r2Deletion.ts:52-55; R2 component README lists all 5)
  • R2_BUCKET (= calgest-prod)
  • R2_ENDPOINT
  • R2_ACCESS_KEY_ID
  • R2_SECRET_ACCESS_KEY
  • R2_TOKEN (required by @convex-dev/r2 component)
  • ASSET_TOKEN_SECRET — HMAC secret for signed download URLs (security.ts:325). Must be byte-identical to the CDN worker’s ASSET_TOKEN_SECRET (apps/cdn/src/index.ts:157) or every attachment/photo download 403s.
  • CDN_URL — base for signed URLs (security.ts:308, throws if unset when issuing a download). Must be https://cdn.calgest.com.
Email
  • RESEND_API_KEY (Resend component)
  • RESEND_FROM_EMAIL (notificationEmail.ts:16, clerkEmailActions.ts:14)
  • CONTACT_EMAIL — optional, defaults to general@calgest.com (contactRequestsActions.ts:27)
  • RESEND_WEBHOOK_SECRETnot required: no Resend delivery-event route is registered in http.ts. Skip unless you add one.
SMS (GatewayAPI)
  • GATEWAYAPI_TOKEN — send auth (sms.ts:340)
  • GATEWAYAPI_WEBHOOK_SECRET — delivery-callback JWT verification; handler returns 503 without it (http.ts:903-907)
AI / observability
  • OPENAI_API_KEY (AI agent)
  • SENTRY_DSN and SENTRY_ENVIRONMENT=production — required by the production configuration gate; backend reporting no-ops outside that explicit environment and schedules scrubbed error delivery through an internal action (lib/observability.ts)
Mobile force-update gate (mobileVersion.ts:22-27) — optional but recommended so you can force-update later:
  • MIN_SUPPORTED_MOBILE_VERSION (defaults 0.0.0 = no floor)
  • IOS_APP_STORE_URL, ANDROID_PLAY_STORE_URL (store-redirect for forced updates)

3. Frontend build-time env vars (set in host/CI before building each bundle)

Dashboard host (Vite SPA — apps/dashboard):
  • VITE_CONVEX_URL, VITE_CLERK_PUBLISHABLE_KEY, VITE_CDN_URL, VITE_SENTRY_DSN
  • VITE_BOOKING_URL — documented in apps/dashboard/.env.example; set it explicitly so the public-booking URL shown in settings targets production.
  • Optional: VITE_APP_VERSION; build-only SENTRY_AUTH_TOKEN/SENTRY_ORG/SENTRY_PROJECT are required by the production deploy for source maps.
Web / marketing host (Vite SPA — apps/web):
  • VITE_CONVEX_URL, VITE_CLERK_PUBLISHABLE_KEY, VITE_SENTRY_DSN, VITE_COMING_SOON (set "true" only if you want the splash)
  • VITE_DASHBOARD_URL — documented and production-validated by the web app. An empty value is rejected before build so sign-in/pricing CTAs cannot silently ship broken.
  • Build-only Sentry source-map vars as above.
EAS / mobile build profiles (apps/mobile/eas.jsonproduction profile uses environment: "production", channel production; set these as EAS env/secrets for that environment):
  • EXPO_PUBLIC_CONVEX_URL, EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY, EXPO_PUBLIC_SENTRY_DSN
  • EXPO_PUBLIC_CLERK_GOOGLE_WEB_CLIENT_ID, EXPO_PUBLIC_CLERK_GOOGLE_IOS_CLIENT_ID, EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME (Google sign-in; the URL scheme is also hardcoded in app.json:65 and must match)
  • SENTRY_AUTH_TOKEN/ORG/PROJECT for source maps. Set SENTRY_AUTH_TOKEN to EAS Sensitive visibility, not Secret, because the eas env:exec preflight cannot read Secret-visibility variables.
  • EAS project owner adamtrip-solutions, bundle/package com.adamtrip.calgest (app.json:63-82). Confirm the store submission profile (submit.production) has credentials.

3a. GitHub Actions configuration

deploy.yml and mobile-build.yml read these. pnpm validate:env fails the job when a required name is empty, so an unset value stops the deploy rather than shipping a broken bundle. Repository secrets (verified present 2026-08-22 unless marked):
  • CONVEX_DEPLOY_KEY (backend deploy)
  • CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID (dashboard, web, CDN)
  • ASSET_TOKEN_SECRET (pushed to the CDN worker by wrangler secret put; must equal the Convex value)
  • SENTRY_AUTH_TOKEN (source-map upload)
  • VITE_CONVEX_URL, VITE_CLERK_PUBLISHABLE_KEY, VITE_CDN_URL, VITE_DASHBOARD_URL, VITE_SENTRY_DSN, VITE_COMING_SOON
  • EXPO_TOKEN is MISSING. Required by the mobile OTA deploy and by mobile-build.yml. Create an Expo access token and add it, or neither mobile job can run.
  • VITE_POSTHOG_KEY exists but nothing reads it. No PostHog reference survives anywhere in the repo. Delete it, along with the POSTHOG_API_KEY and POSTHOG_HOST vars on the prod Convex deployment.
VITE_BOOKING_URL and VITE_APP_VERSION are deliberately not secrets. The workflow hardcodes the booking URL to https://calgest.com and derives the app version from the release tag. Repository variables (gh variable list returns nothing today, so all four are unset):
  • SENTRY_ORG and SENTRY_PROJECT are required. The dashboard and web jobs pass both to validate:env, so both deploys fail until these exist. Set them as repo variables, not secrets.
  • CONVEX_SITE_URL is optional. When unset the backend job derives the readiness URL from VITE_CONVEX_URL by swapping .convex.cloud for .convex.site.
  • WEB_URL is optional and defaults to https://calgest.com.
  • AUTO_DEPLOY_RELEASES should stay unset. Leaving it off means Release Please never dispatches a deploy on its own and every production rollout is a manual, confirmed workflow_dispatch.
Environment production exists and every deploy job targets it, but it has no protection rules. Required reviewers need GitHub Pro on a private repo, which is why release-please.yml defaults to manual dispatch instead. The confirm_production input on deploy.yml is the only human gate.

4. CDN worker (Cloudflare — apps/cdn)

  • wrangler secret put ASSET_TOKEN_SECRET for production (must equal the Convex ASSET_TOKEN_SECRET).
  • Confirm vars.ALLOWED_ORIGINS covers the real dashboard/app origins — currently https://dashboard.calgest.com,https://app.calgest.com (wrangler.jsonc:9-11); CORS at index.ts:8 rejects anything else.
  • Custom domain cdn.calgest.com bound (wrangler.jsonc routes), R2 binding R2_BUCKETcalgest-prod. As of 2026-09-03 the host returns 404, so the worker has not been deployed yet.

AT DEPLOY

  • Deploy backend: npx convex deploy (registers all HTTP routes + crons).
  • Deploy CDN worker: wrangler deploy (prod env).
  • Build/deploy dashboard + web; build/submit mobile via EAS production profile.
  • Register Stripe webhook endpointhttps://<convex-site-url>/stripe/webhook (path from http.ts:177). Subscribe exactly these events (the only ones handled — http.ts):
    • checkout.session.completed
    • checkout.session.async_payment_succeeded
    • checkout.session.async_payment_failed
    • customer.subscription.updated
    • customer.subscription.deleted
    • invoice.payment_failed
    • invoice.paid
    • payment_intent.succeeded
    • customer.updated
    • Copy its signing secret into STRIPE_WEBHOOK_SECRET.
  • Register Clerk webhook endpointhttps://<convex-site-url>/clerk-webhook (http.ts:592). Subscribe: user.created, user.updated, user.deleted, email.created, session.created (http.ts:653-717). Copy signing secret into CLERK_WEBHOOK_SECRET.
  • Register GatewayAPI delivery webhookhttps://<convex-site-url>/gatewayapi/webhook (http.ts:906) with the chosen token as GATEWAYAPI_WEBHOOK_SECRET.

AFTER DEPLOY

5. One-off backfills / migrations

Run once. On a truly empty production DB these are no-ops, but run them anyway (idempotent) — and they are mandatory if any data was imported/migrated from a prior deployment. @convex-dev/migrations runners (packages/backend/convex/migrations.ts); note the deploy workflow only automates runBackfillOrganizationMembershipUserId and runDropInAppNotificationFields — the rest are manual:
  • npx convex run migrations:runBackfillAttachmentEntityType --prod
  • npx convex run migrations:runBackfillOrganizationMembershipUserId --prod (automated in deploy.yml)
  • npx convex run migrations:runBackfillActivationCompleted --prod — billing correctness; feeds the activationCompletedForSubscriptionId guard and the bundled-credit cron.
  • npx convex run migrations:runBackfillAiUsageCosts --prod — re-prices rows whose model had no rate before (embeddings, gpt-4o-mini, gpt-4.1-nano); rows already carrying a cost are left as they were
  • npx convex run migrations:runRepriceAiUsageCosts --prod — re-prices every row with the current rate table (the gpt-5.6-luna rates were 5× too high before 2026-09-03); skip if the deployment never ran the old table
  • npx convex run aiUsage:rebuildAiMonthlyTotalsAll --prod and npx convex run aiUsage:rebuildAiMonthlyTotalsAll '{"monthKey": "YYYY-MM"}' --prod for the previous month — after the backfill and reprice finish; rebuilds the monthly counters that the admin AI usage page (current and previous month) and the fair-use gate read
  • npx convex run migrations:runBackfillAppointmentSeriesProgress --prod
  • npx convex run migrations:runDropInAppNotificationFields --prod (automated in deploy.yml) — unsets the retired inAppEnabled/inAppTemplate columns so the schema can drop them in a later release
Batched internal mutations in backfillSearchTerms.tsre-run each until done: true (500/batch). The search query relies on searchTerms (search.ts:32-50), so skipping them makes pre-existing customers/employees/appointments unsearchable:
  • backfillSearchTerms:backfillCustomers
  • backfillSearchTerms:backfillEmployees
  • backfillSearchTerms:splitCustomerPhones
  • backfillSearchTerms:splitEmployeePhones
  • backfillSearchTerms:backfillAppointments
  • backfillSearchTerms:backfillAppointmentDenormalization
(seed.ts is dev/demo seeding — do not run in production.)

5a. Customer identity linkage preflight

Customer portal access now uses the explicit customers.userId link and fails closed when it is absent. A read-only production check on 2026-07-23 found one Customer role but zero Customer-role memberships, so the current production dataset has no customer user to interrupt. Re-check immediately before launch.
  • Page through customerIdentityMigration:discoverCustomerRoles and run customerIdentityMigration:preflight for every returned role.
  • If Customer-role memberships now exist, use the bounded customerIdentityMigration:backfillBatch mutation first with dryRun: true, then with dryRun: false only for unambiguous linkable IDs. It matches exact normalized email addresses and never auto-links ambiguous/no-match rows.
  • Resolve every remaining status explicitly before enabling customer portal access. If production has acquired memberships since the verified snapshot, use a two-phase release or a planned maintenance window so fail-closed access does not become an unexpected outage.

6. Verify crons registered

After deploy, confirm these appear in the Convex dashboard crons list (crons.ts):
  • roll forward recurrence series (daily 02:00)
  • purge old contact requests (daily 03:30)
  • auto-complete finished appointments (every 1 min)
  • cleanup expired asset upload intents (every 15 min)
  • process bundled credit anniversaries (daily 03:00)
  • cleanup processed stripe events (daily 04:00)
  • reconcile subscriptions from stripe (daily 05:00) — nightly Stripe-truth drift correction
  • purge expired message logs (daily 06:00) — GDPR retention
  • sweep stalled organization deletions (hourly) — GDPR erasure resilience
  • sweep orphaned r2 objects (daily 06:30) — R2 deletion compensation queue

7. Smoke tests

  • GET https://<convex-site-url>/healthz → 200 {"status":"ok"} (http.ts:1048)
  • GET https://<convex-site-url>/healthz/ready → 200 (does a real DB probe; http.ts:1049). The production workflow retries this database-backed route and fails the deploy if it never becomes ready.
  • End-to-end: sign up (Clerk email arrives via Resend), start a subscription checkout (verify ALLOWED_REDIRECT_DOMAINS lets the redirect through and the webhook activates the org), upload an attachment then download it (verifies ASSET_TOKEN_SECRET parity backend↔CDN), send one SMS + confirm delivery webhook flips status.
  • Marketing site: click Sign in / Try for free / a pricing CTA and confirm they land on the dashboard (guards against an empty VITE_DASHBOARD_URL build).