Deployment
Three things get deployed:
- The SPA — Cloudflare Worker hosting the built
dist/assets. - The push cron — separate Cloudflare Worker in
workers/push/running the daily reminder job. See push-notifications.md for that one. - Supabase Edge Functions under
supabase/functions/— on-demand server code (PDF emailer, etc.), deployed viasupabase functions deploy.
Database changes deploy via supabase db push — a separate workflow described below.
All four can go out from your machine with make, or from the GitHub Actions UI — see § Deploying from GitHub Actions. Both routes run the same commands against the same project; the buttons exist so shipping does not depend on one machine holding the credentials.
Environment variables
The single source of truth for what to fill in is .env.example — copy it to .env.local (the file every make/npm script reads) and fill in the values. From there, each value has a final destination. Putting one in the wrong place is the most common deploy footgun, so the table below maps each value to where it ultimately lives.
| Value | .env.local (local dev + build + deploy) | Push-worker secret (wrangler secret put) | Edge-function secret (supabase secrets set) |
|---|---|---|---|
VITE_SUPABASE_URL | yes | ||
VITE_SUPABASE_ANON_KEY | yes | ||
VITE_TURNSTILE_SITE_KEY | yes | ||
VITE_VAPID_PUBLIC_KEY (optional; push toggle) | yes | ||
VITE_PUSH_WORKER_URL (optional) | yes | ||
SUPABASE_PROJECT_REF | yes | ||
SUPABASE_DB_PASSWORD | yes | ||
SUPABASE_ACCESS_TOKEN | yes | ||
SUPABASE_POOLER_HOST | yes | ||
CLOUDFLARE_API_TOKEN | yes (make deploy) | ||
CLOUDFLARE_ACCOUNT_ID | yes (make deploy) | ||
VAPID_PUBLIC_KEY (push worker — same value as VITE_VAPID_PUBLIC_KEY, without the VITE_ prefix) | yes | ||
VAPID_PRIVATE_KEY | yes | ||
SUPABASE_SERVICE_ROLE_KEY (push worker) | yes | ||
ADMIN_TRIGGER_SECRET / BROADCAST_WEBHOOK_URL (optional) | yes | ||
GMAIL_USER / GMAIL_APP_PASSWORD | yes | ||
TURNSTILE_SECRET | yes |
VAPID_SUBJECT, ALLOWED_ORIGINS, TIMEZONE, and CURRENCY are non-secret push-worker config and live in workers/push/wrangler.toml [vars], not in any env file.
.env.local — local dev, build, and deploy
Read by Vite (make dev), by the make targets that talk to your linked Supabase project, and by make deploy (which wraps every wrangler/supabase call in dotenv -e .env.local). It carries every value in .env.example; the sections below explain which of those get redistributed to a secret store at deploy time.
| Var | Where used | Notes |
|---|---|---|
VITE_SUPABASE_URL | src/lib/supabase.ts | Cloud project URL; local is http://127.0.0.1:64421 |
VITE_SUPABASE_ANON_KEY | src/lib/supabase.ts | Public; ships to the browser |
VITE_TURNSTILE_SITE_KEY | Turnstile widget | Required — the build fails without it. Gates both /signup and guest /register |
VITE_VAPID_PUBLIC_KEY | src/lib/push.ts | Push toggle is hidden if unset |
SUPABASE_PROJECT_REF | make link, make push | e.g. abcdefghij |
SUPABASE_DB_PASSWORD | make push, make pull | DB password for migrations |
SUPABASE_ACCESS_TOKEN | Supabase CLI auth | Personal access token |
SUPABASE_POOLER_HOST | make verify (scripts/verify-sync.sh) | e.g. aws-0-ap-east-1.pooler.supabase.com |
CLOUDFLARE_API_TOKEN | make deploy (wrangler) | Only needed for local deploys |
CLOUDFLARE_ACCOUNT_ID | make deploy (wrangler) | Only needed for local deploys |
The build inlines
VITE_*at build time, somake deployproduces a bundle pointing at whatever.env.localholds — make sure those are your cloud values (not a local127.0.0.1stack) before deploying.
Cloudflare push-worker secrets
Set via wrangler secret put from inside workers/push/; they never appear in env files at deploy time. See push-notifications.md § Configure the worker for the full list. The SPA Worker (fundive-app) has no Worker-level secrets — all of its config is baked into the bundle at build time via VITE_*.
Supabase Edge Function secrets
Set via supabase secrets set --project-ref "$SUPABASE_PROJECT_REF" …. create-registration uses GMAIL_USER, GMAIL_APP_PASSWORD, and TURNSTILE_SECRET. See § Supabase Edge Functions.
Workers
Two Cloudflare Workers are deployed separately:
| Worker | Config | Make target |
|---|---|---|
fundive-app | ./wrangler.toml | make deploy-app |
fundive-push | ./workers/push/wrangler.toml | make deploy-push |
make deploy runs both worker deploys in sequence from your machine, reading the CLOUDFLARE_* creds and VITE_* build vars from .env.local — no wrangler login. Use make deploy-app or make deploy-push to ship only one, or the Deploy workflow in GitHub Actions with a target (below). Each target first checks the Cloudflare creds are present and fails fast with a clear message if not.
An experimental
fundiveCLI (npx fundive deploy) also exists for the future thin-deployment model in architecture.md. It's a skeleton (SPA only) — prefermake deploy.
fundive-app (SPA)
make deploy-app first checks the Cloudflare creds are present, then:
npm run build # tsc -b && vite build (VITE_* from .env.local)
. ./.env.local && npx wrangler deploy # CLOUDFLARE_API_TOKEN/ACCOUNT_ID authenticate wranglerwrangler.toml at the repo root:
name = "fundive-app"
main = "src/worker.ts"
compatibility_date = "2026-04-01"
[assets]
directory = "./dist"
binding = "ASSETS"
not_found_handling = "single-page-application"src/worker.ts serves the built assets and falls back to index.html for unknown paths so client-side routes don't 404 on reload. Auth is the CLOUDFLARE_API_TOKEN from .env.local — no wrangler login needed.
fundive-push (cron sender)
make deploy-push runs wrangler deploy from workers/push/ (config: workers/push/wrangler.toml, main = "src/index.ts"), sourcing the same .env.local creds so it authenticates non-interactively. The target installs deps on first run. The worker's own runtime secrets are set separately via wrangler secret put — see push-notifications.md § Configure the worker.
Supabase Edge Functions
Each directory under supabase/functions/ is one deployable function (Deno 2 runtime). Shared code lives under supabase/functions/_shared/ and is imported relatively. make deploy-functions runs supabase functions deploy (no name → ships every function in the directory) against the linked project.
Shop config in the functions
Edge functions read shop config through one seam, _shared/config.ts. It returns the deployment's config when the FUNDIVE_CONFIG secret is set (the resolved fundive.config.ts, serialized to JSON), and otherwise falls back to the platform default — so local functions serve and the vitest handler tests work unchanged. This is the Deno-side counterpart of the frontend's build-time virtual:fundive-config: the browser bundle bakes config in at build, the edge runtime reads it from env at deploy.
npx fundive functions deploy wires this up from a deployment repo: it resolves the deployment's config, supabase secrets set FUNDIVE_CONFIG=<json>, then supabase functions deploy --workdir <platform> (so Supabase finds the platform's functions even though the deployment repo has none of its own). Running make deploy-functions from inside the platform repo still works and uses the platform default (no FUNDIVE_CONFIG set).
create-registration
Single atomic endpoint behind the registration form (RegisterFormBody). Either the body has email + password (guest flow → creates the account with email_confirm: true, bypassing the click-to-confirm gate) or the caller's Bearer JWT identifies an authed user. Either way, the function then UPDATEs the profile, INSERTs the booking, builds a PDF (via _shared/pdf.ts), and emails it via Gmail SMTP to both the shop (siteConfig.app.supportEmail) and the diver. Returns { booking_id, session }; session is populated only on the guest path so the client can setSession immediately. If the booking insert fails on the guest path the just-created auth user is deleted to keep retries clean.
Required secrets (supabase secrets set --project-ref "$SUPABASE_PROJECT_REF" …):
| Secret | What it is |
|---|---|
GMAIL_USER | Gmail account that sends the mail |
GMAIL_APP_PASSWORD | Gmail app password — not the normal password |
TURNSTILE_SECRET | Cloudflare Turnstile secret key, paired with VITE_TURNSTILE_SITE_KEY. Required by create-registration and create-account; both return 500 without it rather than accept an unverifiable token. Cloudflare's always-pass test secret 1x0000000000000000000000000000000AA covers local dev |
SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, and SUPABASE_ANON_KEY are auto-injected by the edge runtime.
Deploy:
make deploy-functions # ships every function under supabase/functions/
supabase secrets set --project-ref "$SUPABASE_PROJECT_REF" \
GMAIL_USER=<shop-gmail-account> \
GMAIL_APP_PASSWORD=<app-password> \
TURNSTILE_SECRET=<turnstile-secret-key>Local testing:
supabase functions serve create-registration --env-file .env.local
# ...then in another shell, curl with a registration body.Supabase schema workflow
Migrations are forward-only. Flow for a schema change:
# 1. Write a new migration file under supabase/migrations/
# Name format: YYYYMMDDHHMMSS_<slug>.sql
# 2. Apply locally and test
make reset # or `make diff` to preview drift, then `make reset`
make test # integration suite exercises the new schema
# 3. Push to cloud
make push # applies to the linked project
# 4. Verify the cloud matches
make verify # schema migration list + row-count parity checkscripts/verify-sync.sh does a two-step check:
supabase migration list --linked— confirms local + cloud agree on applied migrations.- Row-count parity per table across
public+authschemas — catches missing or extra data rows.
Linking a fresh checkout
make link # runs `supabase link --project-ref "$SUPABASE_PROJECT_REF"`
make pull # generates a baseline migration from cloud (one-time if cloud drifted)Pulling data down for local dev
make dump-data # writes cloud data into supabase/seed.sql
make reset # rebuilds local from migrations + seedDeploying from GitHub Actions
Two manual workflows, both workflow_dispatch only — nothing ships because a commit landed. Actions → pick the workflow → Run workflow. A fork gets both; they do nothing until the fork sets its own secrets.
| Workflow | File | Does | Inputs |
|---|---|---|---|
| Deploy | .github/workflows/deploy.yml | make deploy from a clean checkout: SPA Worker, push cron Worker, edge functions | target (all / spa / push-worker / edge-functions), skip_checks |
| Push Supabase migrations | .github/workflows/supabase-push.yml | make push against the linked project | dry_run (default on) |
They are deliberately two buttons. Schema goes first, gets looked at, and then the code that expects it ships — one press that did both would make "the column exists" and "the code needs the column" the same event, which is the ordering that takes a site down.
The migration push dry-runs by default. The run prints supabase migration list (local against cloud) and then supabase db push --dry-run, applying nothing. Reading that list is the review step a local make push does not have. Untick dry_run to apply. Migrations are forward-only and immutable once pushed — there is no undo in this workflow, or anywhere else.
Deploy runs the checks first. Lint, tsc -b and the unit project run before any deploy job, because the commit at the top of main is not necessarily the commit the tab was opened on. skip_checks exists for a rollback that has to go out now; it shows in the run so nobody has to wonder later.
Both refuse to run from anything but main, and both declare environment: production, so whatever reviewers or wait timers a fork sets in Settings → Environments apply to them. Every action is SHA-pinned.
Repository secrets
Settings → Secrets and variables → Actions. Same values as .env.local, which stays the source of truth for a local make deploy:
| Secret | Used by |
|---|---|
CLOUDFLARE_API_TOKEN | Deploy (spa, push-worker) |
CLOUDFLARE_ACCOUNT_ID | Deploy (spa, push-worker) |
VITE_SUPABASE_URL | Deploy (spa) |
VITE_SUPABASE_ANON_KEY | Deploy (spa) |
VITE_TURNSTILE_SITE_KEY | Deploy (spa) — the build fails without it |
VITE_VAPID_PUBLIC_KEY | Deploy (spa); optional, push UI hides itself |
VITE_PUSH_WORKER_URL | Deploy (spa); optional |
SUPABASE_ACCESS_TOKEN | Deploy (edge-functions), Push migrations |
SUPABASE_PROJECT_REF | Deploy (edge-functions), Push migrations |
SUPABASE_DB_PASSWORD | Push migrations |
Nothing else belongs in GitHub. VAPID_PRIVATE_KEY, the service-role key and every other runtime secret stay on Cloudflare (wrangler secret put) and on Supabase (supabase secrets set) — the workflows deploy code, they never rotate a runtime secret.
Release checklist
Small feature or bug fix:
- Run
make testlocally — unit + integration. - If the change touches the schema:
make resetfirst, thenmake test, thenmake pushafter review — or the Push Supabase migrations workflow, dry run first. make deploy— ships both workers (SPA + push cron) and the edge functions. Usemake deploy-appormake deploy-pushif you're touching only one, or the Deploy workflow with atarget.make verify— confirm cloud schema + row counts match local expectations post-deploy.
Rollback
- Frontend:
wrangler deployments listshows history;wrangler rollback <deployment-id>reverts the SPA. - Push worker: same flow inside
workers/push/. - DB: there is no automatic down-migration — write a forward migration that undoes the change, then
make push. Do not edit or delete an applied migration file. Recovery from truly bad migrations is a restore from Supabase's point-in-time backup (dashboard → Database → Backups).
Adopting an existing Supabase project
If your deployment's database was built from a different repo — a shop's own fork, before it started tracking the platform — the schema is already right but the migration registry disagrees, and supabase db push refuses:
Remote migration versions not found in local migrations directory.The registry (supabase_migrations.schema_migrations) lists migration files this repo does not have, and does not list this repo's squashed baseline. Nothing is wrong with your data. Fix the registry, not the schema:
npm run db:backup-prod # always, first
scripts/adopt-existing-deployment.sh --baseline <version> # dry run
scripts/adopt-existing-deployment.sh --baseline <version> --apply
npm run db:push # applies only what's new<version> is this repo's squashed baseline — the timestamp of the first file in supabase/migrations/. The script:
- reads
supabase migration listand computes the remote-only versions, then marks themreverted(their objects stay in the database, untouched); - marks your baseline
appliedwithout running it — the tables already exist, and re-creating them would fail; - touches nothing else.
db pushthen applies only the genuinely new files.
It edits the registry table only: never your schema, never your data.
Rehearse it against a restored snapshot before you point it at production:
ADOPT_DB_URL=postgresql://postgres:postgres@127.0.0.1:64422/postgres \
scripts/adopt-existing-deployment.sh --baseline <version> --applyOne thing the script cannot decide for you. Migrations that existed only in the old repo leave their database objects behind — triggers, functions, columns nothing in this repo references any more. They keep working; they are simply no longer tracked. Review what the reverted migrations created and, if you no longer want those objects, write a forward migration that drops them.
Continuous integration
.github/workflows/ci.yml gates every push and PR to main with four parallel jobs: eslint, tsc -b, the unit vitest project, and deno check over the edge functions.
The Deno job exists because supabase/functions/ is in no tsconfig — tsc never looks at it. It type-checks every edge module whose import graph is free of npm: / jsr: specifiers (resolving those drags in the whole frontend dependency tree and reports errors from unrelated @types). The taint set is computed, not hardcoded, and the job fails if fewer than ten modules qualify — so a refactor cannot quietly reduce it to checking nothing.
Integration and security suites are not in CI: they need the local Supabase stack. Run them with make test.
Nothing deploys from CI. The two workflows that touch a live project — deploy.yml and supabase-push.yml — are manual only; see § Deploying from GitHub Actions.