KPI Media · Internal · One-time setup

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.

7 steps ~4.5 hrs 5 quiet failures 13 acceptance checks

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
Figure 1  Only the arrows are fixed; the rest can move. Step 4 cannot be done before step 2, because the redirect URI is Kong’s hostname. Step 6 needs only step 1, so the junior can be working locally while the rest is still being set up.
  1. GitHub — 30 min

    Two long-lived branches. Protect main: PR required, one approving review, five status checks, no force-push. Protect release: senior only. This is what makes production unreachable for anyone elserelease is 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.

  2. Zeabur — 2 hrs

    Deploy the Supabase template twice into one project, then the Node service twice, each pointing at main and release respectively. Pin the image versions, and pin both stacks to the same versions. Record them in config.toml so local matches too.

    Production hardening: generated DB password to the password manager, Postgres port not publicly exposed on either stack, service_role to 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.

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

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

  5. 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=spf1 breaks both.

  6. Local — 30 min, both engineers

    Docker, the Supabase CLI, Node, pnpm. supabase start runs the same containers production runs; supabase db reset applies 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 grants employee. Someone with no Employee row gets no Account and no access.

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

1 — The dev mail guard

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"]
Figure 2  Rewrite, never filter. Fail closed, never open. A filter can let one row slip through; a rewrite cannot. And a guard that defaults to sending is not a guard — which is why one of the acceptance checks is to unset the variable and watch it throw.
2 — The disk alarm at 70%

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.

3 — Deliberately unalike hostnames

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.

4 — The OAuth redirect direction

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
Figure 3  Google only ever sees GoTrue’s callback. That URL never changes. The SPA origin changes on every preview build, which is exactly why it belongs in Supabase’s allowlist where a wildcard is allowed.
5 — The second SPF record

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 reset runs 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 main deploys 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