Skip to content

Deployment ​

Three things get deployed:

  1. The SPA — Cloudflare Worker hosting the built dist/ assets.
  2. The push cron — separate Cloudflare Worker in workers/push/ running the daily reminder job. See push-notifications.md for that one.
  3. Supabase Edge Functions under supabase/functions/ — on-demand server code (PDF emailer, etc.), deployed via supabase 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_URLyes
VITE_SUPABASE_ANON_KEYyes
VITE_TURNSTILE_SITE_KEYyes
VITE_VAPID_PUBLIC_KEY (optional; push toggle)yes
VITE_PUSH_WORKER_URL (optional)yes
SUPABASE_PROJECT_REFyes
SUPABASE_DB_PASSWORDyes
SUPABASE_ACCESS_TOKENyes
SUPABASE_POOLER_HOSTyes
CLOUDFLARE_API_TOKENyes (make deploy)
CLOUDFLARE_ACCOUNT_IDyes (make deploy)
VAPID_PUBLIC_KEY (push worker — same value as VITE_VAPID_PUBLIC_KEY, without the VITE_ prefix)yes
VAPID_PRIVATE_KEYyes
SUPABASE_SERVICE_ROLE_KEY (push worker)yes
ADMIN_TRIGGER_SECRET / BROADCAST_WEBHOOK_URL (optional)yes
GMAIL_USER / GMAIL_APP_PASSWORDyes
TURNSTILE_SECRETyes

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.

VarWhere usedNotes
VITE_SUPABASE_URLsrc/lib/supabase.tsCloud project URL; local is http://127.0.0.1:64421
VITE_SUPABASE_ANON_KEYsrc/lib/supabase.tsPublic; ships to the browser
VITE_TURNSTILE_SITE_KEYTurnstile widgetRequired — the build fails without it. Gates both /signup and guest /register
VITE_VAPID_PUBLIC_KEYsrc/lib/push.tsPush toggle is hidden if unset
SUPABASE_PROJECT_REFmake link, make pushe.g. abcdefghij
SUPABASE_DB_PASSWORDmake push, make pullDB password for migrations
SUPABASE_ACCESS_TOKENSupabase CLI authPersonal access token
SUPABASE_POOLER_HOSTmake verify (scripts/verify-sync.sh)e.g. aws-0-ap-east-1.pooler.supabase.com
CLOUDFLARE_API_TOKENmake deploy (wrangler)Only needed for local deploys
CLOUDFLARE_ACCOUNT_IDmake deploy (wrangler)Only needed for local deploys

The build inlines VITE_* at build time, so make deploy produces a bundle pointing at whatever .env.local holds — make sure those are your cloud values (not a local 127.0.0.1 stack) 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:

WorkerConfigMake target
fundive-app./wrangler.tomlmake deploy-app
fundive-push./workers/push/wrangler.tomlmake 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 fundive CLI (npx fundive deploy) also exists for the future thin-deployment model in architecture.md. It's a skeleton (SPA only) — prefer make deploy.

fundive-app (SPA) ​

make deploy-app first checks the Cloudflare creds are present, then:

sh
npm run build                                       # tsc -b && vite build (VITE_* from .env.local)
. ./.env.local && npx wrangler deploy                # CLOUDFLARE_API_TOKEN/ACCOUNT_ID authenticate wrangler

wrangler.toml at the repo root:

toml
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" …):

SecretWhat it is
GMAIL_USERGmail account that sends the mail
GMAIL_APP_PASSWORDGmail app password — not the normal password
TURNSTILE_SECRETCloudflare 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:

sh
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:

sh
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:

sh
# 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 check

scripts/verify-sync.sh does a two-step check:

  1. supabase migration list --linked — confirms local + cloud agree on applied migrations.
  2. Row-count parity per table across public + auth schemas — catches missing or extra data rows.

Linking a fresh checkout ​

sh
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 ​

sh
make dump-data    # writes cloud data into supabase/seed.sql
make reset        # rebuilds local from migrations + seed

Deploying 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.

WorkflowFileDoesInputs
Deploy.github/workflows/deploy.ymlmake deploy from a clean checkout: SPA Worker, push cron Worker, edge functionstarget (all / spa / push-worker / edge-functions), skip_checks
Push Supabase migrations.github/workflows/supabase-push.ymlmake push against the linked projectdry_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:

SecretUsed by
CLOUDFLARE_API_TOKENDeploy (spa, push-worker)
CLOUDFLARE_ACCOUNT_IDDeploy (spa, push-worker)
VITE_SUPABASE_URLDeploy (spa)
VITE_SUPABASE_ANON_KEYDeploy (spa)
VITE_TURNSTILE_SITE_KEYDeploy (spa) — the build fails without it
VITE_VAPID_PUBLIC_KEYDeploy (spa); optional, push UI hides itself
VITE_PUSH_WORKER_URLDeploy (spa); optional
SUPABASE_ACCESS_TOKENDeploy (edge-functions), Push migrations
SUPABASE_PROJECT_REFDeploy (edge-functions), Push migrations
SUPABASE_DB_PASSWORDPush 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:

  1. Run make test locally — unit + integration.
  2. If the change touches the schema: make reset first, then make test, then make push after review — or the Push Supabase migrations workflow, dry run first.
  3. make deploy — ships both workers (SPA + push cron) and the edge functions. Use make deploy-app or make deploy-push if you're touching only one, or the Deploy workflow with a target.
  4. make verify — confirm cloud schema + row counts match local expectations post-deploy.

Rollback ​

  • Frontend: wrangler deployments list shows 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:

sh
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:

  1. reads supabase migration list and computes the remote-only versions, then marks them reverted (their objects stay in the database, untouched);
  2. marks your baseline applied without running it — the tables already exist, and re-creating them would fail;
  3. touches nothing else. db push then 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:

sh
ADOPT_DB_URL=postgresql://postgres:postgres@127.0.0.1:64422/postgres \
  scripts/adopt-existing-deployment.sh --baseline <version> --apply

One 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.

Released under the AGPL-3.0-or-later license.