notable work
CASE 02 · RESTU ILAHI sole infrastructure & delivery author · 2025-12 → 2026-07

A one-off wedding site, refactored into a product

6 min read
astronext.jssupabaseterraformgithub actionsoidcaws amplifycloudflare
contents · 6 sections
  1. From one site to four skins
  2. A pipeline that provisions a tenant, not just deploys code
  3. One database, isolated by row, not by instance
  4. Fan-out, and the one time it clobbered something
  5. Pulling the bootstrap infra out of the product repo
  6. What I’d change
astro next.js supabase terraform github actions oidc aws amplify cloudflare

The default trajectory for a wedding website you build for your own wedding is: ship it, use it once, let the repository go quiet. Mine hit that a few months in, and instead of walking away I refactored it into a product: a multi-tenant platform with four interchangeable site designs, a shared back office, and a pipeline that provisions a new client’s custom-domain site end to end. It’s deliberately not self-serve SaaS. Clients can’t configure a site themselves. They come through a two-person marketing team (friends of mine who run the sales side), and I build each one by hand, picking the skin, tuning the details, and dispatching the provisioning myself. I like it hand-touched, so the machinery underneath is product-grade while the finish stays bespoke; if the request volume ever outgrows that, turning it into real self-service is a switch I’ve deliberately left myself room to flip. I designed, built, and operate every layer alone: the Astro front ends, the shared Supabase data layer, the Next.js back office, and the Terraform and GitHub Actions machinery that provisions a new tenant end to end.

flowchart TD
core["canonical template: github template repo"]
s1["glassmorphism skin"]
s2["klasik skin"]
s3["minimalism skin"]
core -->|"use this template"| s1 & s2 & s3
cc["create-client: dispatched workflow"]
cr["client repo: private, topic-tagged"]
cc -->|"create from template · seed secrets + tier"| cr
is["infra run: terraform plan / apply"]
cr --> is
amp["aws amplify app + branch"]
is --> amp
dns["cloudflare dns"]
amp -->|"cname only after cert verifies"| dns
supa["shared supabase postgres: rls per tenant"]
cr -->|"register tenant"| supa
edge["edge functions: rsvp · photo uploads"]
supa --> edge
s3["shared photos bucket: per-tenant prefix"]
edge -->|"pre-signed urls"| s3
mgmt["back office: next.js on workers"]
mgmt --> supa
as built: tenant provisioning, delivery, and the shared data layer

From one site to four skins

The original site is now the canonical template (a GitHub template repository, not a fork source), and three visually distinct variants were spun from it using GitHub’s native template feature, each diverging independently from that point on. They share a config contract (the same client-configuration and card-rendering shape) without sharing git history, which means keeping them in parity is a discipline I enforce with small purpose-built tooling rather than a merge: one script rebuilds the shared design-token unions from all four templates and diffs them against what the base ships, another pulls shared asset libraries across siblings. It’s manual by design: the four skins are meant to diverge, just not accidentally.

A pipeline that provisions a tenant, not just deploys code

Standing up a new client is a dispatched workflow, not a checklist: it creates the private repo from the right template, tags it with a theme-specific topic, copies over the secrets and variables a fresh tenant needs, patches the sales tier into the Terraform variables and client config, and hands off to an infrastructure run that provisions the AWS side. Every site deploys to AWS Amplify; Cloudflare only handles DNS, because that’s the one piece of this stack I didn’t want tied to a single provider. Cutting a new domain over is its own deliberately manual gate: a cutover workflow flips a boolean and re-applies, so the Cloudflare CNAME only gets created after Amplify’s certificate has actually verified. I added that gate after thinking through the alternative: a DNS record pointing at an endpoint that isn’t ready yet is a worse failure mode than a slightly slower cutover.

One database, isolated by row, not by instance

Every tenant site and the internal back office share a single Supabase Postgres project rather than one database per client. Tenant isolation happens at the row level, not the infrastructure level. Each client site ships with its own signed JWT carrying a tenant claim, so the anonymous client hitting Supabase is already scoped by the time it makes its first query; the row-level security policy just checks that claim against the row. Guest photo uploads never touch my server at all. An edge function validates the guest’s passphrase and hands back a pre-signed S3 URL, and the browser uploads directly.

The workflow that bootstraps and tears down tenants carries the same guard pattern I’ve used elsewhere: a job with no if: condition that always runs and fails loudly if it’s dispatched from anywhere but the canonical template repo, or if a client-only action gets dispatched from the template itself. GitHub reports an all-skipped run as green, and a guard job that only runs conditionally can’t catch that, so this one runs unconditionally, on purpose.

Fan-out, and the one time it clobbered something

Template changes reach every live client as a reviewable pull request against that client’s own branch, not a direct push. A workflow archives the template’s current state, syncs it into a fresh clone of each client repo while skipping an explicit ignore-list of client-owned files, and opens a PR only if anything actually changed. A merge-strategy attribute marks the client-specific files so a template merge can’t silently overwrite them. It’s worked as designed almost every time. The one exception: a sync PR once landed and reverted a personal sign-off in a footer that wasn’t yet on the ignore-list, and I had to manually reapply it. Nobody got hurt by it, but it’s the reminder that an ignore-list is a living document, not a one-time decision. I update it the moment a new client-owned field shows up, not after it gets clobbered a second time.

flowchart TD
a["merge to main in a template repo"] --> b["sync workflow fires"]
b --> c["list client repos by topic"]
c --> d["archive template head"]
d --> e["rsync into each client clone: ignore-list applied"]
e --> f{"any diff?"}
f -->|"no"| g["skip: logged in run summary"]
f -->|"yes"| h["push sync branch"]
h --> i["open pr against the client's dev branch"]
i --> j["human review, then merge"]
as built: template fan-out as reviewable pull requests

Pulling the bootstrap infra out of the product repo

The very first version of this had a one-off Terraform directory living inside the core template repo, provisioning the shared state bucket, the GitHub OIDC role, and a photos bucket alongside the actual product code. I pulled all of that out into a standalone personal infrastructure monorepo the same day, with a shared tagging module and a per-project convention, specifically so the next personal project wouldn’t repeat the same setup from scratch. It’s a design built for more than one tenant that currently has exactly one, a bet on future reuse I’m comfortable having made early rather than retrofitting later.

What I’d change

The public marketing site is the one piece of this platform without a documented deployment story. It’s a static build with no adapter or CDN configuration committed anywhere, which means its actual hosting path lives in my memory instead of in the repo. Everything else in this platform is provisioned by something I can point to; that one isn’t yet, and it’s the next thing I’m fixing specifically because “works today, undocumented” is exactly the kind of gap that turns into a bad afternoon later.

filed as CASE 02 · notable work