Demo Environment Runbook
What it is
GiveLink has a persistent demo environment that lives in three places at once:
- A Neon Postgres branch named
demo— forked off the main Neon branch, seeded with a canonical Hope Foundation dataset (campaigns, contacts, payments, events, memberships, email sequences, Stripe Connect mock, Salesforce mock, etc.). - Local development —
.env.localpoints at the Neondemobranch, sopnpm dev,pnpm test, andpnpm db:pushall target it. - A public demo URL at
https://demo.givelink.ai/home— served by a long-lived git branchdemo/showcasethat is fast-forwarded to match main on every push via thesync-demo-branch.ymlGitHub Action. The branch has zero code differences from main.DEV_AUTH_BYPASS=trueis scoped to this one branch so Clerk is bypassed and visitors land directly on the dashboard as Hope Foundation without signing in.
When you add a campaign locally or receive a mock payment, the demo viewer sees it immediately. It is the same database.
The dataset is synthetic (major.donor@example.com, etc.) — even if the URL leaks, no real donor data is exposed. Since 2026-04-08 the URL is intentionally fully public; see History for the rationale.
What to expect from auth routes on demo
demo.givelink.ai has zero auth surface. Every auth-ish route 308s to
app.givelink.ai/*:
| Path on demo | Result |
|---|---|
/sign-in (and sub-paths) | 308 → https://app.givelink.ai/sign-in |
/sign-up (and sub-paths) | 308 → https://app.givelink.ai/sign-up |
/create-org (and sub-paths) | 308 → https://app.givelink.ai/create-org |
/onboarding (and sub-paths) | 308 → https://app.givelink.ai/onboarding |
/admin (and sub-paths) | 308 → https://app.givelink.ai/admin |
Demo uses the DEV_AUTH_BYPASS=true session (dev Clerk pk_test), not
production Clerk. If a sign-in widget ever renders on demo, it's a
regression — see the proxy-routing tests in tests/proxy-hostname.test.ts.
The demo URL
https://demo.givelink.ai/home
Fully public. No Vercel authentication. No Clerk sign-in. Anyone who has the link lands on the Hope Foundation dashboard immediately. Share it freely — DMs, emails, social posts, sales calls, whatever.
The URL is served by three layers working together:
| Layer | What it does |
|---|---|
| Cloudflare DNS | CNAME demo → cname.vercel-dns.com in the givelink.ai zone |
| Vercel custom domain | demo.givelink.ai assigned to the demo/showcase git branch in the GiveLink Vercel project (Settings → Domains) |
DEV_AUTH_BYPASS=true | Vercel env var scoped to Preview (demo/showcase) — skips Clerk middleware and hard-codes the signed-in org to org_seed_hope |
Vercel SSO deployment protection is disabled project-wide (the ssoProtection project field is null). This was a deliberate 2026-04-08 change — see History for why. Do not re-enable it without understanding the trade-off: re-enabling will immediately lock demo.givelink.ai behind a Vercel team-member login and there is no "custom domain exception" escape hatch on the Pro plan without the $150/mo Deployment Protection Exceptions add-on.
How to share with a new viewer
- Send them
https://demo.givelink.ai/home. - Remind them the data is synthetic but the app is real — campaigns and contacts they create will persist for everyone else using the demo.
That's it. No token, no password, no onboarding step.
How it stays current with main
The demo environment is fully hands-off. Three automatic sync mechanisms keep code, schema, and data current so you never need to manually rebase, migrate, or redeploy:
| Surface | Workflow | What it does | Typical latency |
|---|---|---|---|
| Git branch / code | .github/workflows/sync-demo-branch.yml | Fast-forwards demo/showcase to main on every push | ~15 seconds |
| Database schema | .github/workflows/sync-demo-schema.yml | Runs pnpm db:push against the Neon demo branch when schema files change on main | ~2 minutes |
| Vercel deployment | Native Vercel git integration | Builds demo/showcase whenever GitHub pushes it | ~2 minutes |
Put together: a merge to main propagates through code → schema → Vercel build → live at demo.givelink.ai in roughly 3–4 minutes, with no human touch.
If demo/showcase somehow diverges from main
The sync workflow tries a fast-forward first (the normal case). If someone has pushed directly to demo/showcase — which nobody should do — it falls back to a merge commit. If that conflicts, the workflow fails loudly and opens a failure in the Actions tab. Recovery:
# Check out demo/showcase locally, merge main, resolve, push
git fetch origin
git checkout demo/showcase
git merge origin/main
# ...resolve conflicts in your editor...
git push origin demo/showcaseNever force-push demo/showcase unless you fully understand the downstream impact. The branch has no commits of its own — any force-push is either a no-op (fast-forward) or destroys the auto-sync history (bad).
If the schema sync fails
The most common cause is a potentially destructive change (column drop, type change without default) that drizzle-kit refuses to auto-confirm. The recovery procedure:
# From the main worktree (the only one whose .env.local points at demo)
pnpm db:push --force # only after confirming demo data is safe to loseIf --force would destroy demo data you want to keep, use db:push interactively and answer n to the destructive prompts — then plan a manual data migration before re-running.
See ADR-0045 for the full schema-sync rationale.
Preview branches vs. demo branch: Preview branches (auto-created per PR by the Neon-Vercel integration) use
drizzle-kit migrateviascripts/vercel-build.sh— see ADR-0047. The demo branch continues to usedb:pushvia thesync-demo-schema.ymlAction because it's a long-lived branch with accumulated data where migration ordering doesn't apply. These are separate mechanisms with separate trade-offs.
How to add demo data (without wiping everything)
The dataset grows organically — by using the app or running one-off inserts. It does not get rebuilt from pnpm db:seed.
Do NOT re-run
pnpm db:seedagainst the demo branch. The seed script begins withTRUNCATE organizations CASCADEand wipes every row the dataset has accumulated since bootstrap. The seed is a bootstrap tool, not a refresh tool.
Preferred: use the app UI
- Open the demo URL (or run
pnpm devlocally). - Log in as Hope Foundation (auto-bypassed).
- Create the campaign / event / email template / etc. through the dashboard.
- The row persists in Neon and is immediately visible to anyone else using the demo.
One-off insert script
For rows you cannot create through the UI (e.g., historical donations with old dates):
# Always scope inserts to org_seed_hope
# Create a script in scripts/ that uses the Drizzle client
pnpm tsx scripts/add-demo-row.tsNever insert into org_seed_sunrise — that is a tenant-isolation tripwire, not demo content.
How to put the demo behind access control again
There is currently no access control on demo.givelink.ai — it is intentionally public. If that changes (e.g. regulatory pressure, leaked dataset, spam, abuse), the fastest way to restrict access is to re-enable Vercel Deployment Protection project-wide:
# Re-enables Vercel SSO on all preview deployments, including demo.givelink.ai
curl -s -X PATCH "https://api.vercel.com/v9/projects/prj_VHgADLCnuPeD5qajxFdnrYAF1ARq" \
-H "Authorization: Bearer $VERCEL_TOKEN" \
-H "Content-Type: application/json" \
-d '{"ssoProtection": {"deploymentType": "all_except_custom_domains"}}'Important trade-off: turning SSO back on locks
demo.givelink.aibehind a Vercel team-member login, because theall_except_custom_domainsoption only exempts production custom domains — it does not exempt custom domains assigned to preview branches. Visitors who are not members of the Datawake Vercel team will hit a "This deployment is protected" page.If you need finer-grained control (public for some viewers, blocked for others), the only clean option on the Pro plan is the Deployment Protection Exceptions add-on (~$150/mo) which lets you whitelist specific domains.
Cheaper alternatives if you need to restrict temporarily
- Delete the Cloudflare CNAME record.
demo.givelink.aistops resolving entirely. Viewers get a generic DNS error. Restore by re-adding theCNAME demo → cname.vercel-dns.comrecord. - Remove the custom domain assignment in Vercel.
demo.givelink.aireturns a 404 from Vercel. Restore viaProject Settings → Domains → Add → assign to demo/showcase branch. - Unset
DEV_AUTH_BYPASSon the demo/showcase scope. The URL still resolves but visitors hit the Clerk sign-in wall. This is the least disruptive option if you want to keep the environment alive but hide it temporarily.
How to rotate the Neon database password
If the demo branch credentials leak (accidentally committed, shared in chat, pasted into a tool), rotate them immediately. Rotation touches both the Neon control plane and seven Vercel env vars.
Step 1 — Rotate in Neon
# via neonctl (preferred)
neonctl roles reset-password <role> --project-id <project> --branch demo
# OR via the Neon console: Dashboard → demo branch → Roles → Reset passwordCopy the new connection strings. You will need:
- The pooled connection string (for
DATABASE_URL,POSTGRES_URL,POSTGRES_PRISMA_URL) - The unpooled connection string (for
DATABASE_URL_UNPOOLED,POSTGRES_URL_NON_POOLING,POSTGRES_URL_NO_SSL) - The raw password (for
POSTGRES_PASSWORD)
Step 2 — Update all seven Vercel env vars scoped to demo/showcase
# Remove each old value, then re-add
for VAR in DATABASE_URL DATABASE_URL_UNPOOLED POSTGRES_URL POSTGRES_URL_NON_POOLING POSTGRES_URL_NO_SSL POSTGRES_PRISMA_URL POSTGRES_PASSWORD; do
vercel env rm "$VAR" preview --git-branch=demo/showcase --yes
done
# Re-add with the new values (shell-escape tricky URL characters carefully)
vercel env add DATABASE_URL preview demo/showcase --value "$NEW_POOLED" --yes
vercel env add DATABASE_URL_UNPOOLED preview demo/showcase --value "$NEW_UNPOOLED" --yes
# ...etcGotcha: ampersands in connection strings. Passing a URL with
&characters via--value "$VAR"can silently store an empty value when the shell expands the ampersand as a background job. Either escape the value carefully or use the Vercel REST API/v10/projects/{id}/envdirectly. If the env var lists as empty aftervercel env add, that is what happened — remove and re-add via the API.
Step 3 — Update .env.local for local development
# Back up the current .env.local first
cp .env.local .env.local.bak-$(date +%Y%m%d)
# Update DATABASE_URL and the six other POSTGRES_* vars to the new values
# Restart pnpm devStep 4 — Force a rebuild of demo/showcase
Vercel does not automatically rebuild when env vars change. Push an empty commit from the demo/showcase worktree (see How to refresh for the git -C pattern if you need to drive it from another worktree):
DEMO_WT=$(git worktree list --porcelain | awk '/^worktree / {wt=$2} /^branch refs\/heads\/demo\/showcase$/ {print wt}')
git -C "$DEMO_WT" commit --allow-empty -m "chore(demo): rebuild after password rotation"
git -C "$DEMO_WT" push origin demo/showcaseThe 18 Vercel env vars the demo branch needs
The demo/showcase preview inherits env vars from two scopes.
Scoped to Preview (demo/showcase) (11 vars) — demo-specific
| Variable | Source |
|---|---|
DATABASE_URL | Neon demo branch pooled |
DATABASE_URL_UNPOOLED | Neon demo branch direct |
POSTGRES_URL | Neon demo branch pooled |
POSTGRES_URL_NON_POOLING | Neon demo branch direct |
POSTGRES_URL_NO_SSL | Neon demo branch no-ssl variant |
POSTGRES_PRISMA_URL | Neon demo branch prisma-compat |
POSTGRES_PASSWORD | Neon demo branch role password |
POSTGRES_HOST | Neon demo branch host |
DEV_AUTH_BYPASS | true — skips Clerk middleware |
NEXT_PUBLIC_DEV_AUTH_BYPASS | true — hides sign-in UI on the client |
DEV_BYPASS_ORG_ID | org_seed_hope — the org the bypass falls back to |
Inherited from generic Preview scope (7 vars) — shared with other preview branches
| Variable | Why it's needed |
|---|---|
CLERK_SECRET_KEY | Even with bypass on, src/lib/env.ts validates its presence at boot |
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY | Same — validated at boot |
CLERK_WEBHOOK_SECRET | Svix webhook validator initializes at startup |
STRIPE_SECRET_KEY | Stripe client initializes at startup |
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY | Same — validated at boot |
STRIPE_WEBHOOK_SECRET | Webhook verifier initializes at startup |
RESEND_API_KEY | Resend client initializes at startup |
These seven were missing from the generic Preview scope initially, which caused the first few demo builds to 500 on every route — see Troubleshooting.
Troubleshooting
"demo.givelink.ai isn't resolving"
Cause: Cloudflare DNS record was deleted, or the CNAME is pointing at the wrong target.
Fix: Verify the record exists and points at Vercel:
dig demo.givelink.ai +short
# Expected: cname.vercel-dns.com.
# 76.76.21.164
# 66.33.60.34If the CNAME is missing, re-create it via the Cloudflare API:
# Zone ID for givelink.ai is a652b093974ebd6be1465a72029ff2c8
curl -s -X POST \
-H "X-Auth-Email: $CLOUDFLARE_EMAIL" \
-H "X-Auth-Key: $CLOUDFLARE_API_KEY" \
-H "Content-Type: application/json" \
"https://api.cloudflare.com/client/v4/zones/a652b093974ebd6be1465a72029ff2c8/dns_records" \
-d '{"type":"CNAME","name":"demo","content":"cname.vercel-dns.com","ttl":1,"proxied":false,"comment":"Demo environment — points at demo/showcase branch on Vercel"}'"demo.givelink.ai returns 401 with a _vercel_sso_nonce cookie"
Cause: Vercel SSO deployment protection has been re-enabled. Preview custom domains are not exempt from the all_except_custom_domains setting, so demo.givelink.ai is now behind a Vercel team login.
Fix: Disable SSO protection project-wide (only if the public-demo policy still applies — read How to put the demo behind access control again first to make sure this is what you want):
curl -s -X PATCH "https://api.vercel.com/v9/projects/prj_VHgADLCnuPeD5qajxFdnrYAF1ARq" \
-H "Authorization: Bearer $VERCEL_TOKEN" \
-H "Content-Type: application/json" \
-d '{"ssoProtection": null}'"demo.givelink.ai serves old code even though main was just updated"
Cause: The sync-demo-branch.yml workflow failed or hasn't run. Without it, demo/showcase doesn't pick up new commits from main.
Fix: Check the Actions tab for a recent failing run of "Sync demo branch". Re-run it manually via the workflow-dispatch trigger, or perform the fast-forward by hand:
git fetch origin
git checkout demo/showcase
git merge --ff-only origin/main
git push origin demo/showcaseIf the fast-forward fails because demo/showcase has diverged, see How it stays current with main for the merge fallback.
"I'm getting 500 on all routes of the demo URL"
Cause: The seven shared Preview-scope secrets (CLERK_SECRET_KEY, STRIPE_SECRET_KEY, RESEND_API_KEY, etc.) are missing from the Vercel Preview scope. src/lib/env.ts validates them at boot, so the entire app crashes before any route renders.
Fix: Add them to the Preview scope (not demo/showcase-specific):
vercel env add CLERK_SECRET_KEY preview --value "$KEY" --yes
# ...repeat for the other sixThen trigger a rebuild by pushing any commit to main (the sync workflow will propagate it to demo/showcase).
"vercel env add succeeded but the env var shows empty"
Cause: Shell expansion of special characters (&, $, backticks) in the value parameter. The shell treats & as a background-job delimiter and truncates the value before vercel receives it.
Fix: Use one of:
- Escape the value:
vercel env add VAR preview demo/showcase --value "$(printf '%s' "$VALUE")" - Use the Vercel REST API
/v10/projects/{id}/envdirectly, which takes JSON not shell args - Pipe from stdin:
echo "$VALUE" | vercel env add VAR preview demo/showcase
"The demo is logged in as the wrong org"
Cause: DEV_BYPASS_ORG_ID is not set or points at the wrong org. The bypass in src/lib/auth.ts falls back to this value when DEV_AUTH_BYPASS=true.
Fix: Confirm the env var is set to org_seed_hope in the demo/showcase scope:
vercel env ls preview demo/showcase | grep DEV_BYPASS_ORG_ID"I accidentally ran pnpm db:seed against the demo branch"
Cause: The seed script truncates all organizations rows before inserting. Any demo data accumulated since bootstrap is gone.
Fix: There is no automatic recovery. The dataset must be re-bootstrapped from scratch, which is acceptable because the seed now covers the full dashboard surface (events, memberships, email sequences, etc.). Re-run the seed and rebuild whatever the last demo viewer was looking at.
To prevent recurrence: add a guard to scripts/seed.ts that requires an env flag like ALLOW_DEMO_WIPE=1 when DATABASE_URL contains /demo.
"Demo briefly showed the dashboard then bounced me to the app sign-in page"
This was a bug: demo served the Clerk sign-up widget, which created a
dev-Clerk user and then followed NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL
to production, where the dev session didn't exist. Fixed 2026-04-18 on
branch fix/demo-proxy-auth-surface (PR link added after merge). If you
see this symptom again, something broke the DEMO auth-page redirect
branch in src/lib/proxy-routing.ts; check
tests/proxy-hostname.test.ts for the regression guards.
History
The demo environment has gone through three access-control configurations. The current state — fully public at demo.givelink.ai — is the result of two independent problems we fixed in quick succession.
2026-04-06: bypass token URL (original design)
Shared via a DM-able URL of the form:
https://givelink-git-demo-showcase-datawake-vb.vercel.app/?x-vercel-protection-bypass=<TOKEN>&x-vercel-set-bypass-cookie=true
The <TOKEN> was a Vercel Protection Bypass for Automation token. It worked but had two problems: (1) the URL was ugly, and (2) the demo/showcase git branch only picked up new code when someone manually rebased it onto main. After the donations→payments / donors→contacts rename landed in PR #205, the demo branch fell ~30 commits behind and started serving stale pre-rename code.
2026-04-08: custom domain + auto-sync workflow
Two changes landed the same day:
- PR #212 added
.github/workflows/sync-demo-branch.yml, which fast-forwardsdemo/showcaseto match main on every push. The branch can no longer fall behind. demo.givelink.aiwas added to the Vercel project as a custom domain scoped to thedemo/showcasegit branch, plus a Cloudflare CNAME record. This gave the demo a stable, brandable URL instead of the long auto-generated preview URL.
At this point the demo was at https://demo.givelink.ai/home but still had Vercel SSO protection turned on. The setting was ssoProtection.deploymentType: "all_except_custom_domains", which we expected to exempt all custom domains. It doesn't — it only exempts production custom domains. Preview custom domains assigned to git branches are still protected. That meant demo.givelink.ai prompted for a Vercel team-member login, which blocked anyone outside the Datawake Vercel team.
2026-04-08: SSO disabled (current state)
ssoProtection was set to null project-wide. The rationale:
- Production (
givelink.ai) is unaffected. Vercel SSO never applied to the production custom domain; only Clerk auth does. - Random
.vercel.apppreview URLs (unrelated previews from other branches) are technically now public, but they are unguessable hashes and still require Clerk auth becauseDEV_AUTH_BYPASSis scoped only todemo/showcase. demo.givelink.aiis now fully public, which is the whole point.- The alternative (paying $150/mo for Deployment Protection Exceptions to whitelist
demo.givelink.aispecifically) did not offer enough additional security to justify the cost.
If the public-demo policy changes — for example, a nonprofit asks us to restrict access before their board sees it — re-enable SSO using the commands in How to put the demo behind access control again.
References
scripts/seed.ts— the bootstrap script that seeds Hope Foundationsrc/proxy.ts— the Clerk middleware that honorsDEV_AUTH_BYPASSsrc/lib/auth.ts— theDEV_BYPASS_ORG_IDfallbacksrc/lib/env.ts— the production guard that rejectsDEV_AUTH_BYPASSunderNODE_ENV=production.github/workflows/sync-demo-branch.yml— automatic code sync: fast-forwardsdemo/showcaseto match main on every push.github/workflows/sync-demo-schema.yml— automatic schema sync to the Neon demo branch on merge to maindocs/superpowers/specs/2026-04-06-demo-environment-design.md— the original design document- Memory:
project_persistent_demo_dataset.md - ADR-0037 — local-only development until launch
- ADR-0045 — auto-sync schema to the demo branch on merge to main