CI/CD & Deployment
CI/CD & Deployment
Surfc ships from three Git repositories plus a set of Supabase Edge Functions. Every deploy is Git-triggered and static-host-managed — there is no manual upload step for the websites. This page is the single reference for how each pipeline works and how database migrations are applied and verified.
At a glance
| Surface | Repo | Host | Build command | Output | Trigger | Node |
|---|---|---|---|---|---|---|
App (app.surfc.app) | surfc/ | Netlify | npm run build | dist/ | push to main | 20 (CI) |
Help (help.surfc.app) | surfc/ | Cloudflare Pages | npm run docs:build (vitepress build docs) | docs/.vitepress/dist/ | push to main | 20 |
Marketing (surfc.app) | surfc-web/ | Cloudflare Pages | npm run build (astro build) | dist/ | push to main | 22 |
Intranet (intranet.surfc.app) | surfc-intranet/ | Cloudflare Pages | npm run build (astro build) | dist/ | push to main | 22 |
| Edge Functions | surfc/ | Supabase | supabase functions deploy <name> | — | manual | Deno 2 |
The static-host binding (which repo deploys where) is configured in the provider dashboard’s Git connection, not in any in-repo file.
1. surfc/ → Netlify
The React PWA at app.surfc.app.
- Build:
npm run build(vite build) →dist/. - Trigger: push to
mainauto-deploys;mainis always deployable. - Config:
netlify.tomlat the repo root:- SPA fallback:
/*→/index.html(200) so deep links resolve. /policies/*→https://surfc.app/policies/:splat(301, forced) — policies moved to the marketing site post-SUR-218./.well-known/change-password→/change-password(302).manifest.webmanifestserved asapplication/manifest+json.
- SPA fallback:
- Node: there is no
.nvmrc; CI pins Node 20. - Env vars: client-side
VITE_*keys (VITE_SUPABASE_URL,VITE_SUPABASE_ANON_KEY,VITE_APP_URL,VITE_PUBLIC_POSTHOG_PROJECT_TOKEN,VITE_PUBLIC_POSTHOG_HOST, plusVITE_TURNSTILE_SITE_KEY) are set in the Netlify dashboard, not committed. See Supabase setup for what each one is.
The help.surfc.app VitePress site is built from the same surfc/ repo
(npm run docs:build over docs/) and deployed to Cloudflare Pages on the
same push-to-main trigger.
2. surfc-web/ → Cloudflare Pages
The Astro marketing site at surfc.app.
- Build:
npm run build(astro build) →dist/. - Output mode:
output: 'static'with no adapter. The@astrojs/cloudflareadapter was tried and rejected (SUR-256) — itsdist/{client,server}split breaks the Cloudflare Pages publish-dir convention. Re-add only if real SSR is needed. - Node:
.nvmrc=22;package.jsonengines.node=>=22.12.0. - Cache headers:
public/_headerssetsCache-Control: public, max-age=31536000, immutableon/_astro/*, the favicon,og-image.png, andicon-*.png;max-age=86400onrobots.txt. - Env vars (set in the Cloudflare Pages dashboard):
PUBLIC_SUPABASE_ANON_KEY,PUBLIC_SUPABASE_URL,PUBLIC_APP_URL,PUBLIC_POSTHOG_PROJECT_TOKEN,PUBLIC_POSTHOG_HOST. - Blog publish flow: posts are MDX files in the Astro content collection
(
src/content/, SUR-256). Publishing a post is just merging it tomain— Cloudflare Pages rebuilds and deploys automatically. There is no separate publish script or CMS. - CI (
.github/workflows/):playwright.yml— Playwright E2E onchromium+mobile-chrome(Node 22); blocks PRs.quality.yml— Lychee link check overdist/**/*.html(.lycheeignoreis the escape hatch). Lighthouse CI is currently disabled pending SUR-254 cleanup.
3. surfc-intranet/ → Cloudflare Pages
This repo — the internal wiki + admin at intranet.surfc.app.
- Build:
npm run build(astro build) →dist/. - Node:
.nvmrc=22;engines.node=>=22.12.0. - Cache headers:
public/_headers— immutable 1-year on/_astro/*and the favicon, 1-day onrobots.txt. - No environment variables, no test/lint/typecheck scripts.
npm run buildis the only verification gate — it surfaces.astrotemplate errors and Starlight content-collection schema violations (a Markdown file missingtitlefrontmatter fails the build). - Access control: the entire
intranet.surfc.appdomain (covering/,/wiki/*, and/admin/*) sits behind Cloudflare Access (Zero Trust, email allowlist) — live since SUR-283. Cloudflare Pages preview URLs are also gated. Access is enforced at the Cloudflare edge; there is no application-level auth code in this repo. See the Intranet Foundations spec §2.8 for the design rationale.
4. Edge Functions → Supabase
Edge Functions live in surfc/supabase/functions/:
anthropic-proxy, approve-waitlist, create-billing-portal-session,
create-checkout-session, delete-account, image-upload,
me-entitlements, stripe-webhook, waitlist-signup (shared code in
_shared/).
-
Deploy (manual, one function at a time):
Terminal window supabase functions deploy <name># e.g.supabase functions deploy anthropic-proxysupabase functions deploy me-entitlements -
Secrets are NOT passed on the deploy command. Set every secret in Supabase Dashboard → Settings → Edge Functions → Secrets before deploying. Functions that need missing secrets fail loud at module load (e.g. the Stripe functions require
STRIPE_SECRET_KEY,STRIPE_WEBHOOK_SECRET,STRIPE_PRICE_ID_PRO_MONTHLY,STRIPE_PRICE_ID_PRO_ANNUAL;anthropic-proxyrequiresANTHROPIC_API_KEYand the Azure Content Safety trio). -
JWT verification overrides:
supabase/config.tomlsetsverify_jwt = falseforapprove-waitlist,waitlist-signup, andstripe-webhook(they authenticate by other means — webhook signature, database trigger, captcha). All other functions verify the Supabase JWT. -
CI:
.github/workflows/edge-functions-test.ymlruns a Deno v2 type-check + unit tests wheneversupabase/functions/**changes in a push or PR tomain.
5. Database migrations — apply order & verification
Migrations are plain SQL files in surfc/supabase/migrations/, numbered
0001, 0002, … and always applied in numeric order.
-
Author a new migration:
Terminal window npm run migrate:new -- describe_the_change# creates supabase/migrations/<n>_describe_the_change.sql -
Apply locally (Docker required):
Terminal window npm run db:start # supabase start — brings up the local stack, applies all migrationsnpm run db:reset # supabase db reset --no-seed — re-apply from a clean DB -
Apply to production: paste the new migration’s SQL into Supabase Dashboard → SQL Editor → Run, in numeric order (apply each un-applied file from lowest number to highest). There is no automated production migration runner.
-
Verify the live schema matches the contract:
Terminal window npm run check:schema # node scripts/check-schema.js — requires DATABASE_URLDATABASE_URLis the Session-mode pooler connection string from Supabase Dashboard → Settings → Database.check-schemavalidates required tables/columns, RLS policies, thenote-imagesstorage bucket, and its storage policy. Exit0= clean,1= drift. -
Keep the contract in sync: when a migration adds a required table, column, or policy, update
scripts/schema-contract.jsin the same PR and flag it so the migration is applied before the dependent code deploys. -
CI:
db-test.yml— on any push/PR tomaintouchingsupabase/**, spins up the local Supabase stack (Node 20) and runsnpm run test:db(schema / RLS / RPC / trigger / soft-delete / concurrency).schema-smoke-test.yml— weekly cron (0 9 * * 1, Mondays 09:00 UTC) runsnpm run check:schemaagainst the live database to catch out-of-band SQL edits. Also runnable on demand viaworkflow_dispatch.
Related
- Onboarding — first-day setup for a new engineer.
- How We Work — the 7-step workflow loop and branching rules.
- Supabase setup — env vars, secrets, local stack.
- Approve-waitlist runbook — Edge Function operational detail.