<!--
Pre-Production Hardening Checklist

Part of the free Claude Code template library from BILT AI — https://biltai.io/claude-templates

These are the actual playbooks used to build and ship production SaaS apps with
Claude Code. They're written to be handed to an agent as-is: point Claude at one
and it has the full pattern, the schema, the gotchas, and the "don't do this"
list. Paths, project refs, and org names have been replaced with placeholders
(<your-app>, <supabase-project-ref>, <your-github-org>) — search for "<" and
fill in your own before running.

Free to use, modify, and ship. No attribution required.
-->

# Pre-Production Hardening Checklist

**The final gate before flipping an app to public production traffic.**

Portable across any Next.js + Supabase + Vercel + Trigger.dev SaaS. Built from the BKE / Kompozy launch sequence. Reference repo: `reference app`.

---

## 0. Use this checklist

**When to run:** the day before announcing publicly, before flipping DNS to the new host, before enabling paid ads, before sharing a link to a >100-person audience. Run it again any time the architecture changes materially.

**How to run:** top-to-bottom in one sitting. Every unchecked box is a launch blocker until justified. If something is "N/A" write why in a comment next to the box — silent skips are how production fires start.

**Outputs:**
- A green run of this file (all boxes checked or explicitly waived)
- A short launch note in the repo's `lab-notes.md` recording the date + git SHA hardened
- One archived CWV report under `docs/audits/cwv-YYYY-MM-DD/`

---

## 1. Secrets audit

- [ ] No secrets in code — `git log -p | rg "sk_live_|SUPABASE_SERVICE_ROLE_KEY=|postmark-server-token|sk-ant-"` returns nothing
- [ ] No secrets in client bundle — server-only keys live behind API routes, never imported by client components
- [ ] `.env.example` exists with placeholder values for every required var
- [ ] `.env.local` and `.env*.local` are in `.gitignore`
- [ ] Vercel env vars set for Production, Preview, AND Development environments
- [ ] Trigger.dev env vars set in the Trigger.dev dashboard (workers cannot read Vercel env)
- [ ] OAuth credentials (Stripe, Postmark, GHL, HeyGen, etc.) are live-mode keys for prod, not test-mode
- [ ] Service-role key NEVER in client-side env — only browser-safe keys are prefixed `NEXT_PUBLIC_*`
- [ ] Rotated any key that was ever pasted into chat, screenshots, or a livestream (see `bke_livestream.md`)

---

## 2. Auth + edge gating

- [ ] `src/proxy.ts` configured with default-deny for `/api/*`
- [ ] `PUBLIC_API_PREFIXES` reviewed line-by-line — every entry has a one-line justification comment
- [ ] `PROTECTED_PREFIXES` covers `/dashboard` and `/onboarding`
- [ ] `ADMIN_PROTECTED_PREFIXES` covers `/admin/dashboard` and `/api/admin`
- [ ] `supabase.auth.getUser()` used in edge middleware — NEVER `getSession()` (cookie-only, not verified server-side)
- [ ] Every new `/api/*` route added since last hardening pass is either in `PUBLIC_API_PREFIXES` or behind `requireUser()` — see `feedback_bke_proxy_default_deny.md`

---

## 3. RLS verification

- [ ] Every user-data table has RLS enabled — run `select tablename from pg_tables where schemaname='public' and rowsecurity=false` and confirm only public/lookup tables appear
- [ ] Default-deny policies + explicit per-action SELECT/INSERT/UPDATE/DELETE rules on each tenant table
- [ ] Service-role bypass verified — admin operations from server actions still work after RLS is on
- [ ] **Cross-tenant leak test** — log in as user A, hit `/api/<resource>?id=<user-B-resource-id>` directly. Must return empty or 403, never user B's data
- [ ] No `SECURITY DEFINER` views or RPCs expose data without an `auth.uid()` check
- [ ] Storage buckets have RLS policies (not just public/private toggle) — verify with a logged-out `curl` to a private object URL

---

## 4. Security headers

Set explicitly in `next.config.ts` `headers()` — do NOT trust Vercel defaults.

- [ ] `X-Frame-Options: DENY` (or `SAMEORIGIN` for embed-friendly surfaces)
- [ ] `X-Content-Type-Options: nosniff`
- [ ] `Referrer-Policy: strict-origin-when-cross-origin`
- [ ] `Permissions-Policy: camera=(), microphone=(), geolocation=()` — lock unused features
- [ ] `Strict-Transport-Security: max-age=63072000; includeSubDomains; preload`
- [ ] `Content-Security-Policy` — start permissive (`script-src 'self' 'unsafe-inline'`), tighten as inline styles migrate out
- [ ] Verify with [securityheaders.com](https://securityheaders.com) — target grade A or better

---

## 5. Rate limiting

Reference: `src/lib/rate-limit.ts`

- [ ] Upstash Redis configured for prod (with in-memory fallback for dev only)
- [ ] Every public endpoint rate-limited per-IP AND per-user
- [ ] LLM-call endpoints have a global rate limit (cost circuit breaker — prevents one bug from burning $5k overnight)
- [ ] Auth endpoints (`/api/auth/signup`, `/api/auth/signin`, password reset) rate-limited to block brute force
- [ ] 429 response includes `Retry-After` header so clients back off correctly

---

## 6. Webhooks

- [ ] Stripe webhook signature verification active — NOT bypassed via dev-mode flag in prod
- [ ] Postmark webhook HTTP Basic auth configured + secret rotated
- [ ] Customer-supplied outbound webhook URLs have SSRF guards — cross-ref `WEBHOOK-RECEIVER-PLAYBOOK.md`
- [ ] Webhook event idempotency tables created (`stripe_webhook_events`, `postmark_webhook_events`, etc.) with unique constraint on the provider event id
- [ ] Webhook routes added to `PUBLIC_API_PREFIXES` in `src/proxy.ts` so they aren't blocked by edge auth

---

## 7. Database hygiene

- [ ] All migrations applied to prod — cross-ref `SUPABASE-MIGRATION-PLAYBOOK.md` and `bke_supabase_migrations_applied.md`
- [ ] Indexes on every foreign-key column + every column used in WHERE/ORDER BY at scale
- [ ] No unused tables / columns in `pg_stat_user_tables` — clean up debt before launch, harder later
- [ ] Daily backups configured (Supabase Pro includes PITR — confirm it's actually enabled in project settings)
- [ ] Integer columns are never receiving JS floats — see `feedback_postgrest_int_columns.md`

---

## 8. Email auth

- [ ] SPF record on sending domain (`v=spf1 include:<ESP> -all`)
- [ ] DKIM record per ESP selector (Postmark, Resend, GHL, etc.)
- [ ] DMARC at minimum `p=quarantine` with `rua=` + `ruf=` reporting addresses
- [ ] Deliverability checker (mail-tester.com, mxtoolbox) shows grade A — cross-ref `EMAIL-INFRASTRUCTURE-PLAYBOOK.md`
- [ ] Sending domain is NOT the apex used for the app (use `mail.example.com` so reputation issues don't tank the root)

---

## 9. Error monitoring

- [ ] Vercel Analytics enabled on the prod project
- [ ] Trigger.dev runs dashboard bookmarked and email/Slack alerts on failed runs configured
- [ ] Supabase logs accessible — confirm SQL editor + log explorer load for the prod project
- [ ] Optional: Sentry or equivalent for client-side error capture with source maps uploaded per deploy
- [ ] Alert routes configured — Slack channel or email distribution list for critical failures (webhooks failing, Trigger.dev workers crashing, 5xx spike)
- [ ] One synthetic failure tested end-to-end — throw on purpose, confirm the alert lands

---

## 10. CWV baseline

- [ ] Lighthouse audit on landing + one cluster hub + one spoke (cross-ref `CWV-PERFORMANCE-AUDIT-PLAYBOOK.md`)
- [ ] LCP <2.5s mobile, CLS <0.1, INP <200ms (lab estimate)
- [ ] No layout-blocking third-party scripts above the fold
- [ ] Reports archived to `docs/audits/cwv-YYYY-MM-DD/` so post-launch regressions are diffable

---

## 11. Customer-facing legal

- [ ] `/privacy` page (privacy policy — lawyer-reviewed or boilerplate from termly/iubenda customized)
- [ ] `/terms` page (terms of service)
- [ ] `/refund-policy` page (cancellation + refund terms — required by Stripe for some account types)
- [ ] Cookie consent banner if EU traffic expected
- [ ] Stripe legal entity + business details accurate in Stripe Dashboard (mismatched info delays payouts)
- [ ] All three legal pages linked from footer on every public route

---

## 12. SEO + GEO baseline

Cross-ref `TOPICAL-AUTHORITY-PLAYBOOK.md`.

- [ ] `/sitemap.xml` returns 200 AND every URL in the sitemap returns 200 — HEAD-audit the full list pre-launch, don't ship a sitemap with 308s (wastes crawl budget)
- [ ] `/robots.txt` allows the public surface, disallows `/dashboard`, `/admin`, `/api`
- [ ] `/llms.txt` published with the canonical brand description + sitemap pointer
- [ ] Static OG images per route — NO per-slug `opengraph-image.tsx` (Vercel build time explodes)
- [ ] `/apple-icon.png` is a 180×180 PNG (NOT SVG — iOS rejects SVG)
- [ ] Person + Organization JSON-LD schema with real social URLs (verified live, not placeholders)

---

## 13. Domain + DNS

- [ ] Vercel domain attached + verified (green check in dashboard)
- [ ] HTTPS issuance via Let's Encrypt or Sectigo succeeded — CAA records (if set) allow the issuer
- [ ] All DNS records reviewed: MX, TXT (SPF / DMARC / site-verification), CNAME, ALIAS
- [ ] Subdomain strategy decided — www vs apex, with one redirecting to the other (pick one, redirect the other 301)
- [ ] DNS TTL lowered to 300s 24h before launch in case of an emergency cutover

---

## 14. Deploy verification

Cross-ref `feedback_verify_before_push.md` — 10 failed Vercel deploys in one BKE session from skipping these.

- [ ] Local build passes: `npm run build` — zero errors, zero warnings worth caring about
- [ ] All routes prerender successfully — zero `<failed>` in build output
- [ ] No TypeScript errors: `npx tsc --noEmit`
- [ ] Lint passes: `npm run lint`
- [ ] Trigger.dev deploys to prod with SDK version pinned to the installed `@trigger.dev/sdk` version (CLI aborts on mismatch with `@latest`)
- [ ] Next.js dev runs with `--webpack` (not Turbopack) — see `no_turbopack.md`

---

## 15. Smoke tests (run after deploy lands)

- [ ] Homepage returns 200 and renders without console errors
- [ ] `/signup` flow end-to-end — signup → onboarding → dashboard with a real fresh email
- [ ] `/signin` flow with OAuth providers (each one separately if multiple)
- [ ] First paid conversion via Stripe test cards — subscription appears in Stripe + DB
- [ ] First scheduled action publishes successfully (cross-platform if applicable)
- [ ] First admin action audit-logged correctly
- [ ] First customer-facing email arrives — transactional (welcome) AND drip campaign first touch
- [ ] One critical Trigger.dev worker run completes from a real trigger
- [ ] `/api/track` (or equivalent analytics endpoint) receives at least one anonymous event
- [ ] `/reviews`, `/pricing`, `/compare` (and any other marketing routes) all render 200

---

## 16. Documentation

- [ ] `CLAUDE.md` updated for app-specific decisions (Known Issues & Decisions section current)
- [ ] `README.md` has dev setup instructions a new agent / contractor can follow without asking
- [ ] Architecture spec (e.g. `KONTENT_ENGINE_SPEC.md`) covers current state, not stale plans — search for "Deployment TODOs" section and clear or move resolved items
- [ ] Lab notes appended for every fix-attempt that taught something during the hardening pass

---

## Common pitfalls

- ❌ Don't deploy without local build verification — `feedback_verify_before_push.md` records 10 failed Vercel deploys in a single session from skipping this
- ❌ Don't skip the cross-tenant RLS test — multi-tenancy bugs are reputation killers, and they're invisible until a customer screenshots someone else's data
- ❌ Don't trust default Vercel security headers — set them explicitly in `next.config.ts`
- ❌ Don't skip the Trigger.dev redeploy when worker-imported code changes — workers run yesterday's bundle until you redeploy, even if Next.js is shipping today's code
- ❌ Don't skip the sitemap HEAD-audit — 308s in the sitemap waste Google crawl budget and bury new pages
- ❌ Don't ship without `/privacy` + `/terms` — Stripe Checkout refuses to enable some flows without them, and the rejection email arrives at the worst possible moment
- ❌ Don't use `git add -A` for the hardening commit — see `feedback_session_scoped_commits_no_ask.md`
- ❌ Don't ship destructive DB sync routines — upsert-only, deletes from explicit user actions only. See `feedback_never_destructive_db_writes.md`
- ❌ Don't store provider URLs (DALL-E, HeyGen, Kie) directly — they expire in 1h. Persist to Supabase Storage. See `feedback_never_store_provider_urls.md`

---

## Kickoff prompt

> Execute the Pre-Production Hardening Checklist at `PRE-PRODUCTION-HARDENING-CHECKLIST.md` for this app. Go top-to-bottom. For each box, either check it off after verifying in the live system / repo, or leave it unchecked with a one-line reason. At the end, produce a launch-readiness report: every unchecked box becomes either a blocker (must fix before launch) or a waived item (justified in writing). Pause after §3 (RLS) for review — that's the highest-risk section.
>
> Reference repo: `reference app` — when a pattern is unclear, read the BKE file rather than re-deriving.

---

**Reference files in BKE:**
- `src/proxy.ts` — edge auth + default-deny
- `src/lib/rate-limit.ts` — Upstash + in-memory fallback
- `next.config.ts` — security headers
- `KONTENT_ENGINE_SPEC.md` — search for "Deployment TODOs"
- `.env.example` — env-var contract
