Repository navigation
Production Readiness
How gracechords.com is configured for production, what to set in each dashboard, how to verify each piece, and what to do when one breaks. The pre-launch checklist is at the end.
The quickest health check for everything on this page:
npm run smoke:prod -w @gracechords/webIt checks TLS, the HTTP→HTTPS redirect, security headers, asset caching, key routes, the 404 status, robots/sitemap, deep-link files and the crash-report endpoint, then exits non-zero if anything fails. Point it at a preview with SMOKE_ORIGIN=https://<hash>.gracechords.pages.dev.
In code
- All client config comes from
VITE_*variables, documented in.env.example. The web build reads the repo-root.env(ViteenvDir). - The Supabase
preconnect/dns-prefetchhints are generated fromVITE_SUPABASE_URLby theinject-supabase-preconnectplugin inapps/web/vite.config.js. The project URL is no longer hard-coded inindex.html, and a build without the variable just leaves the hints out. - Server-only secrets (
SUPABASE_SERVICE_ROLE_KEY,BOT_WEBHOOK_TOKEN,OPENAI_API_KEY, …) are read only by Pages Functions, Workers and Node build scripts. They are never prefixedVITE_. - Production vs. development:
import.meta.env.PRODgates the service worker and crash reporting, so neither runs undernpm run dev. Cloudflare's Production and Preview environments have separate variable sets (see Build-and-Deploy andMONOREPO_MIGRATION.md).
Platform settings: Cloudflare Pages → gracechords → Settings
| Setting | Value |
|---|---|
| Root directory | apps/web |
| Build command | cd ../.. && npm ci && npm run build -w @gracechords/web |
| Build output | dist |
| Production branch | main |
| Variables (Production and Preview) |
VITE_SUPABASE_URL, VITE_SUPABASE_ANON_KEY, VITE_R2_PUBLIC_URL, VITE_PPTX_WORKER_URL, VITE_CLOUDINARY_*, VITE_ENABLE_DISCLAIMER, VITE_CONTACT_EMAIL
|
| Variables (Production only) |
SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, BOT_INTERNAL_URL, BOT_WEBHOOK_TOKEN, BOT_API_TOKEN, TELEGRAM_BOT_TOKEN, OPENAI_API_KEY, BIBLE_CDN_URL
|
VITE_COMMIT_SHA |
Set it to the commit ($CF_PAGES_COMMIT_SHA) so each deploy gets a fresh service-worker cache. Without it the build falls back to a timestamp, which also busts the cache but can't be traced to a commit. |
Supabase → Authentication → URL Configuration: Site URL https://gracechords.com. Redirect URLs include https://gracechords.com/auth/callback, https://gracechords.com/reset-password, the /app/... variants used by the mobile app, and https://*.gracechords.pages.dev/** if you sign in on previews.
Test: open a Preview deploy, sign in, and load a song. In DevTools → Elements, <head> should contain <link rel="preconnect" href="https://<project>.supabase.co">.
Troubleshooting
-
Preview builds green but the page is blank:
VITE_SUPABASE_URL/VITE_SUPABASE_ANON_KEYaren't set on Preview. The client throwssupabaseUrl is requiredat load. -
Build fails at
generate-seo-pages.mjs:SUPABASE_SERVICE_ROLE_KEYis missing on Production. Previews skip that step automatically. - OAuth returns to the wrong host: add the exact URL to Supabase Redirect URLs. A trailing slash matters.
None of this lives in the repo (there is no supabase/config.toml), so it is recorded here.
Sign in with Apple on the web. Native iOS sign-in uses the app's bundle ID and needs no secret. The web uses Apple's OAuth flow, which needs a Services ID and a client secret.
- Apple Developer → Identifiers → Services IDs → create
com.gracechords.weband enable Sign in with Apple. - Configure it with Primary App ID
com.gracechords.app. This is what makes Apple return the same user id on the web as on iOS, so an iOS user lands in their existing account. With a different primary App ID they would get a second account. - Domains:
<project>.supabase.co. Return URL:https://<project>.supabase.co/auth/v1/callback. - Keys → create a key with Sign in with Apple (primary App ID
com.gracechords.app) and download the.p8. Keep it in the password manager, never in the repo. - Generate the secret:
node apps/web/scripts/apple-client-secret.mjs --team J7Y8NYZ48Q --key-id <KEY_ID> --services-id com.gracechords.web --p8 <path>. - Supabase → Authentication → Providers → Apple: Client IDs
com.gracechords.web,com.gracechords.app(Services ID first; the bundle ID keeps native iOS working), Secret Key = the JWT from step 5.
The Apple secret expires (Apple caps it at 6 months), so it is rotated automatically. The Rotate Sign in with Apple secret workflow (.github/workflows/rotate-apple-secret.yml) runs on the 1st of every month, mints a fresh 180-day secret, and writes it to Supabase through the Management API. Because each secret lasts about five months longer than the gap between runs, several failed runs in a row still leave a working secret. A failed run emails you.
- One-time setup: add the repository secrets
APPLE_P8(the full.p8contents),APPLE_KEY_ID,APPLE_TEAM_ID,APPLE_SERVICES_ID,SUPABASE_ACCESS_TOKEN(Supabase → Account → Access Tokens: Resource access Project (this project only), with Read-write on exactly two permissions, Application services → Auth Config and Project → Project Settings. Supabase requires both for this API call, and everything else stays None. Project Settings can also pause or delete the project, so treat this token as sensitive) andSUPABASE_PROJECT_REF. Then run the workflow once by hand from the Actions tab. - Apple has no way to test a secret short of a real sign-in. After the first run, or after changing any of those secrets, try "Continue with Apple" on the site.
- If the workflow is ever disabled (GitHub pauses scheduled workflows after 60 days without repository activity), web Apple sign-in and linking fail when the last secret lapses. iOS keeps working. To recover, re-enable the workflow and run it, or run step 5 by hand and paste the value.
Auth settings (Authentication → Providers → Email, and Authentication → Sign In / Providers):
- Secure password change: ON. A password change on a session older than 24h needs reauthentication. The apps prove it with the current password, or for Apple/Google-only accounts setting a first password, with an emailed code.
- Allow manual linking: ON. Profile → Security (web) and Account → Sign-in methods (app) connect Apple/Google to an existing account. Supabase still auto-links identities that share a verified email; manual linking covers the rest, chiefly Apple Hide My Email addresses.
-
Password policy: minimum 8, with lowercase, uppercase, digits and symbols. It must match
PASSWORD_POLICYinpackages/core/src/auth/passwordPolicy.ts, which both apps validate against. - Leaked-password protection needs a paid plan, so it is off.
Email delivery. Reset links, confirmation links and reauthentication codes all go through Supabase's mailer.
- Use custom SMTP. The built-in sender is rate-limited to a handful of emails per hour.
- Register the SMTP sending domain/address in Apple Developer → Sign in with Apple for Email Communication (with SPF). Without it, mail to
@privaterelay.appleid.comaddresses is silently dropped, so Apple users with Hide My Email never get reset or reauthentication emails.
Test (on a Preview whose URL is in Redirect URLs):
- Continue with Apple on
/loginin Safari and in Chrome. It should land in the same account you use on iOS. - Profile → Security: change your password, then connect and disconnect Google.
- With an Apple-only test account: set a password, then sign in with email + password.
In code
-
Two error boundaries. The app-wide one in
src/App.jsxcatches crashes in the shell. A route-level one wraps<Outlet />inLayout, keyed on the path, so a crashing page keeps the navbar usable and navigating away clears the error. Both usesrc/components/ErrorBoundary.jsx, which shows a copyable error report (readable on phones) plus Reload / Go to home page links. -
Stale-deploy recovery.
src/utils/app/lazyRoute.jsandmain.jsxreload once, after purging the asset caches, when a route chunk fails to load. The boundary shows "This page didn't finish loading" if that doesn't help. -
404. Unknown routes render
src/pages/NotFoundPage.jsxinside the nav layout (localized,noindex). Cloudflare also returns real HTTP 404 status for them because404.htmlexists. -
Offline.
src/components/OfflineBanner.jsxshows a band under the announcement strip whilenavigator.onLineis false. Previously visited pages keep working through the service worker (see Offline-Support). - There's no separate 500 page. There's no server rendering, so a "500" can only come from a Pages Function, and those return JSON to the UI that called them. A crash in the browser is the error boundary's job.
-
Loading states. Route chunks suspend to a loading fallback. Data hooks (
useSongs,usePosts, setlists) exposeloading/error/retry, and exports show theBusyoverlay. Audit new async UI against that pattern.
Test
- Visit
/definitely-not-a-page: you should get "Page not found" with the navbar, andcurl -sI https://gracechords.com/definitely-not-a-pageshould return404. - In DevTools → Network, set "Offline": the yellow offline band should appear, and clear when you go back online.
- Route boundary: covered by
src/__tests__/productionErrors.test.jsx.
Troubleshooting
-
"Something went wrong" right after a deploy: usually a stale tab. Reloading fixes it. If it persists, visit
/?reset_sw=1. -
Wrong page title after navigating: the page has no
<Helmet><title>. It falls back to "GraceChords" via the default inApp.jsx, so add a title to that page.
In code
-
Assets. Vite content-hashes everything under
/assets/.public/_headersnow serves those withCache-Control: public, max-age=31536000, immutable, while HTML andsw.jskeep Cloudflare's revalidate-every-time default so deploys take effect immediately. The home hero ships responsive WebP (640–1536) with a build-injected preload; the PNG originals insrc/assets/are no longer imported, so they never ship. RunANALYZE=1 npx vite build(inapps/web) for a bundle treemap atdist/stats.html. -
Console output. Production code has no stray
console.log. The three remaining calls are opt-in PDF traces (localStorage.pdfFontTrace/PDF_TRACE).console.error/console.warnare kept on purpose because they help debugging on users' devices. -
Security headers (
public/_headers):frame-ancestors 'none',nosniff,strict-origin-when-cross-origin, HSTS (one year; noincludeSubDomainsorpreloaduntil every subdomain is HTTPS-only), and aPermissions-Policythat turns off camera, microphone, geolocation, payment and USB, none of which the web app uses. -
SEO. Per-route
<Helmet>titles, descriptions, canonicals and Open Graph tags.generate-seo-pages.mjspre-renders/songs/:id,/posts/:slug,/privacy,/termsand/delete-account, andgenerate-sitemap.mjswrites the sitemap.robots.txtnow keeps auth, profile, admin and editor routes out of search results. -
Responsive.
useIsMobile()drives layout switches, and the mobile action sheet, dock and pane tabs cover the narrow layouts.
Test
-
curl -sI https://gracechords.com/assets/<any index-*.js>should showimmutable. - Run
npm run pagespeed -w @gracechords/web(readsPAGESPEED_API_KEYfrom.env), https://pagespeed.web.dev, or Lighthouse in Chrome DevTools at Mobile. Track LCP and CLS. - Responsive: check Home, Songs, a song, Setlist and Songbook at 375 px and 768 px in DevTools device mode. There should be no horizontal scrolling.
- SEO: paste a song URL into https://search.google.com/test/rich-results and Facebook's Sharing Debugger.
Troubleshooting
-
Users stuck on old JS after a deploy: hashed filenames change on every build, so
immutablecan't pin old code. Check thatindex.htmlitself isn't long-cached by a Cloudflare Cache Rule (it must revalidate). -
Google indexes a login page: it was crawled before the
robots.txtchange. Use Search Console → Removals.
Crash reports (self-hosted, no third party, no Telegram)
-
src/utils/app/errorReporter.jssends one report per distinct error (at most 5 per page load) from production builds: the error boundary,window.onerror, and unhandled rejections. Each report contains the error, stack, page path (never the query or hash), user agent and release SHA. It never includes user IDs, emails or content. Chunk-load errors and extension noise are skipped. -
functions/api/client-error.jsaccepts only same-origin JSON up to 16 KB, logs every report, and stores it in Supabase throughlog_client_error()using the existingSUPABASE_URL+SUPABASE_SERVICE_ROLE_KEY, so there are no new variables. -
public.client_errors(migration20261105120000_client_errors.sql) keeps one row per distinct error withcount,first_seenandlast_seen, plus the most recent path, browser and release. Rows unseen for 90 days are pruned automatically, and the table stops accepting new errors past 5,000 distinct rows. RLS is on with no policies, so only the service role can read or write it. -
Apply the migration by hand (the repo never uses
db push):supabase db query --linked -f supabase/migrations/20261105120000_client_errors.sql, or paste it into Dashboard → SQL Editor. Until then reports still reach the Cloudflare logs, and the function logs[client-error] store failed 404.
Reading the crash log
- Supabase Dashboard → Table Editor →
client_errors→ sort bylast_seendescending. - Or run this in the SQL Editor for "what broke this week, worst first":
select count, last_seen, name, message, path, release, left(stack, 300) as stack from client_errors where last_seen > now() - interval '7 days' order by count desc, last_seen desc;
- Once a bug is fixed and deployed, delete its row (
delete from client_errors where fingerprint = '…') so a recurrence shows up as new. -
releaseis the commit SHA, sogit show <release>tells you which deploy the user was running.
Visit statistics: Cloudflare Web Analytics Open it from the Cloudflare dashboard → Analytics & Logs → Web Analytics → the gracechords.com site. (On the Pages project, Metrics → Web Analytics links to the same place.)
- Top of the page: visits (arrivals from outside the site), page views, and page load time for the selected range. The time picker is at the top right.
-
Breakdown panels: click any row to filter the whole page to it.
-
Path: which pages people read. Filter Path → contains
/songs/to rank songs, or/readingfor Daily Word. - Referer: where visitors come from (Google, Facebook, a church's site, direct).
- Country, Device type (how much is mobile), Browser and OS.
-
Path: which pages people read. Filter Path → contains
- Core Web Vitals tab: LCP, INP and CLS as good / needs improvement / poor. The debug view lists the paths and page elements behind poor scores, so it's where to look before any performance work.
- Numbers are sampled and aggregated, with no per-visitor detail by design. Expect them to run lower than server request counts, because bots and visitors who block scripts aren't counted.
Questions it answers well: are people using the site more than last month, which songs to prioritise, what share is on phones (useful for deciding web versus app work), and did a deploy slow pages down.
Rate limit the crash endpoint (WAF) Cloudflare dashboard → select the gracechords.com domain → Security → WAF → Rate limiting rules tab → Create rule. In the newer dashboard this is Security → Security rules → Create rule → Rate limiting rule.
| Field | Value |
|---|---|
| Rule name | client-error rate limit |
| If incoming requests match | Field URI Path, operator equals, value /api/client-error
|
| With the same characteristics | IP (the free plan's only option) |
| When rate exceeds | 20 requests per 10 seconds |
| Then take action | Block for 10 seconds |
| Click Deploy. The free plan allows one rule and fixes the period and block duration at 10 seconds, which is all this needs. A real browser sends at most 5 reports per page load, so normal users never get near it. |
Test: run this and expect 403s followed by 429s once the limit kicks in:
for i in $(seq 1 30); do curl -s -o /dev/null -w "%{http_code} " -X POST https://gracechords.com/api/client-error -H 'Origin: https://test.invalid'; done; echoLogs: Workers & Pages → gracechords → Deployments → (deployment) → Functions → Real-time logs. Filter on [client-error]. These logs are live-only, and the Supabase table is the durable record.
Automatic deployment is Cloudflare Pages' GitHub integration. Every push to main deploys production, and every other branch gets a Preview URL. Confirm under Settings → Builds & deployments that production branch = main and preview branches = all non-production branches.
Uptime / domain / SSL
-
.github/workflows/prod-smoke.ymlruns the smoke test when the "Cloudflare Pages" check completes onmain, and every 6 hours. GitHub emails you when a run fails, which makes it a free uptime and certificate-expiry monitor. - SSL was checked from outside on 2026-11-05 and is already correct: HTTP gets a 301 to HTTPS, TLS 1.0 and 1.1 are refused, and TLS 1.2 and 1.3 are accepted. The certificate is Cloudflare-managed (Google Trust Services, 90-day, auto-renewed about 30 days before expiry). To review the settings: gracechords.com → SSL/TLS → Overview (encryption mode Full (strict), which only matters for proxied records other than Pages) and SSL/TLS → Edge Certificates (Always Use HTTPS on, Minimum TLS Version 1.2, Automatic HTTPS Rewrites on).
- Custom domain: Pages → gracechords → Custom domains shows
gracechords.comas Active.old.gracechords.comis the archived GitHub Pages site and is unrelated.
Test
- Run
npm run smoke:prod -w @gracechords/weband expect all checks to pass. - Crash report end to end, after deploy and after applying the migration: in the production site's DevTools console, run
setTimeout(() => { throw new Error('crash-report test') }), then refreshclient_errors. A row with that message should appear, and running it again bumpscount. Delete the row afterwards. - Analytics: visit the site, then check Web Analytics after a few minutes.
Troubleshooting
-
No row in
client_errors: check the function's real-time logs. If[client-error] store failed 404appears, the migration isn't applied. A401meansSUPABASE_SERVICE_ROLE_KEYis missing on the Pages Production environment. No[client-error]line at all means the browser never sent the report. Reports only come from production builds, and the error may be a skipped kind (chunk-load or extension). -
A table full of junk: someone is posting fake reports. Check that the WAF rule is deployed, then
delete from client_errors where first_seen > '<time>'. - Smoke test fails on "crash-report endpoint" with 405: the function isn't deployed. The build probably failed, so check Pages → Deployments.
- Certificate check fails: see SSL/TLS → Edge Certificates. A stuck renewal is usually a CAA DNS record that excludes the issuing CA.
Before merging
-
npm testpasses andnpm run lintis clean -
npm run i18n:checkpasses; new non-English strings carryneedsReview: true -
npm run buildsucceeds locally or on the Preview deploy - Preview deploy loads Home, Songs, a song, Setlist, Songbook, Daily Word and
/definitely-not-a-page - Sign-in (email, Google, Apple) works on the Preview, if its URL is in Supabase Redirect URLs
- Any new migration was applied by hand and RLS was tested with a
user-role account
One-time platform setup
- Cloudflare Pages Production and Preview variables match the table in §1
-
VITE_COMMIT_SHAset on the Pages build - SSL/TLS: Always Use HTTPS and Minimum TLS 1.2 (verified 2026-11-05)
- WAF rate-limiting rule on
/api/client-error - Web Analytics enabled on the Pages project
-
client_errorsmigration applied in Supabase - Supabase Site URL and Redirect URLs set for gracechords.com
- Apple Services ID set in Supabase, and the
Rotate Sign in with Apple secretworkflow's secrets added and run once - Supabase: Secure password change ON, Allow manual linking ON, custom SMTP registered with Apple's private relay
- Google Search Console property verified and sitemap submitted
After each deploy
- "Production smoke test" workflow is green (or run
npm run smoke:prod) - Hard-refresh and spot-check the changed feature
- No new or climbing rows in
client_errors(sort bylast_seen)
-
User Guide
-
Editing & Admin
-
Developer Guide
-
Infrastructure
-
Mobile