Standing it all up, once
Roughly half a day, in order, done by the senior — except the local step, which both engineers do on their own machine. This screen is the shape and the traps. The literal commands live in docs/setup.md, where they are actually run.
The shapeSeven steps
%%{init: {"flowchart": {"padding": 14, "nodeSpacing": 40, "rankSpacing": 48, "useMaxWidth": true}}}%%
flowchart TB
S1["1 · GitHub
30 min
two branches, both protected"]
S2["2 · Zeabur
2 hrs
the template twice, versions pinned"]
S3["3 · Cloudflare
1 hr
two Pages projects · R2 · CNAMEs at GoDaddy"]
S4["4 · Google OAuth
20 min
one client per environment"]
S5["5 · Resend
20 min
domain verified · the mail guard"]
S6["6 · Local
30 min · both engineers"]
S7["7 · Slack
10 min
one channel, one webhook"]
S1 -->|"the repo has to exist
before anything builds from it"| S2
S2 -->|"Pages needs branches;
Zeabur gives you the hostnames"| S3
S2 -->|"the callback URL is
Kong's hostname"| S4
S3 -->|"custom domains first,
then the CNAME"| S4
S3 -->|"DNS records land
in the same place"| S5
S1 -->|"clone and run"| S6
S2 --> S7
-
GitHub — 30 min
Two long-lived branches. Protect
main: PR required, one approving review, five status checks, no force-push. Protectrelease: senior only. This is what makes production unreachable for anyone else —releaseis the only branch that deploys production, and one person can push it.Invite the junior with write access; branch protection stops them reaching either branch. Actions secrets are dev keys only.
-
Zeabur — 2 hrs
Deploy the Supabase template twice into one project, then the Node service twice, each pointing at
mainandreleaserespectively. Pin the image versions, and pin both stacks to the same versions. Record them inconfig.tomlso local matches too.Production hardening: generated DB password to the password manager, Postgres port not publicly exposed on either stack,
service_roleto the password manager and nowhere else, separate Resend keys per stack.The junior receives two values by hand — a dev URL and a dev anon key. No Zeabur invitation is issued to anyone.
-
Cloudflare — 1 hr
Two Pages projects, one per branch. The dev project also builds every other branch as a preview, which is where per-PR review URLs come from — automatic, nothing to configure.
Three CNAMEs, added at GoDaddy, production only. Add each hostname as a custom domain on its Pages project or Zeabur service first, then point the record. Three R2 buckets, one API token per use.
-
Google OAuth — 20 min
One client per environment, each restricted to the Workspace domain. Authorized redirect URI is GoTrue’s callback, and nothing else. SPA origins — including the dev wildcard — go in Supabase’s allowlist, not in Google.
-
Resend — 20 min
Sending domain verified with SPF, DKIM and DMARC in GoDaddy. One API key per environment. Check the existing SPF record before adding one — Workspace almost certainly has one already, and a second
v=spf1breaks both. -
Local — 30 min, both engineers
Docker, the Supabase CLI, Node, pnpm.
supabase startruns the same containers production runs;supabase db resetapplies migrations plus the synthetic seed in about thirty seconds.No account provisioning step. The seed creates 32 Employees and zero Accounts. Sign in with your own Workspace account and the trigger finds your Employee by
work_email, creates the Account and grantsemployee. Someone with no Employee row gets no Account and no access. -
Slack — 10 min
One channel, one incoming webhook, all four alarms through it. A quiet channel should mean a healthy system, so nothing else goes in it.
The trapsFive things that fail quietly
Each of these looks fine on the day and bites weeks later.
seed.sql carries the real Workspace addresses of all 32 staff, because sign-in matches on work_email. That means a dev payslip job reads live inboxes. So the transport rewrites every recipient outside production to an allowlist, puts the original address in the subject, and throws if the allowlist is unset. Rewriting, not filtering — a filter can let a row through and a rewrite cannot. Failing closed, because the alternative is a missing environment variable mailing thirty-two colleagues a test payslip.
%%{init: {"flowchart": {"padding": 14, "nodeSpacing": 42, "rankSpacing": 44, "useMaxWidth": true}}}%%
flowchart TB
M["A mail is about to send
to a real @kpimedia.sg address"] --> Q1{"APP_ENV = production?"}
Q1 -->|"yes"| REAL["send to the real recipient"]
Q1 -->|"no"| Q2{"RESEND_ALLOWLIST set?"}
Q2 -->|"yes"| RW["rewrite every recipient
to the allowlist address
original address into the subject"]
Q2 -->|"no, or APP_ENV unset"| TH["THROW
send nothing at all"]
Dev and production share a disk. A runaway dev migration that fills it stops production Postgres accepting writes. It is not yet confirmed that Zeabur offers this alarm. If it does not, this becomes a job of ours that checks and posts to Slack — it cannot simply be dropped.
Not supabase-dev and supabase-prod. Those differ by four characters and one of them will end up in a --db-url at 11pm. Use names like kpi-sandbox and kpi-payroll, so a wrong paste looks wrong.
Google holds GoTrue’s callback. Supabase holds the SPA origins, including the dev wildcard. Getting it backwards — registering SPA origins with Google — breaks sign-in on every preview deploy, which quietly removes the entire point of preview deploys.
%%{init: {"flowchart": {"padding": 14, "nodeSpacing": 38, "rankSpacing": 44, "useMaxWidth": true}}}%%
flowchart TB
subgraph RIGHT["✓ Right — sign-in survives every preview deploy"]
direction TB
GR["Google Cloud Console
authorized redirect URI:
GoTrue's callback, and nothing else
fixed per environment"]
SR["Supabase auth settings
redirect allowlist:
the SPA origins
wildcards go HERE"]
end
subgraph WRONG["✗ Wrong — sign-in breaks on every PR"]
direction TB
GW["Google Cloud Console
authorized redirect URI:
the SPA's own origin"]
NOTE["a preview deploy gets a NEW origin
every time, and Google has never
heard of it → rejected"]
GW --> NOTE
end
Merge Resend’s include into the record Workspace already has. Two v=spf1 records on one domain break mail on that domain. And it is still unconfirmed which domain hosts Workspace — staff are @kpimedia.sg but the setup doc points at kpimedia.co.
Done whenThirteen checks
Setup is finished when all of these are true — not when the steps have been performed.
supabase db resetruns clean — migrations and seed agree- Junior can clone,
supabase start, and sign in locally with their own Google account - Junior can open a PR and see CI run
- A merge to
maindeploys dev, and the dev URL works - A PR preview URL works and can sign in with Google
- Junior has no production access — verify by having them try
- Junior has no Zeabur access at all — verify by having them try
- A test email from dev arrives at your address, not the employee’s
- Unsetting the allowlist on dev makes the send throw — a guard nobody has watched fail is not a guard
- Disk alarm set at 70% on the shared Zeabur project
- Backups are running to R2, and the alarm fires when one does not
- A restore has been rehearsed once, end to end
- Both legal entities carry their real registered name and registration number