Running HealthspanWire on GitHub + Supabase + Resend
A companion to MIGRATION_PLAN.md, which assumed Vercel. This one answers a
different question: can the same site run with no Vercel at all — GitHub
Pages for hosting, GitHub Actions for scheduled work, Supabase for data and
auth, Resend for mail?
Short answer: yes, and this is a good moment to decide it. Four things change shape, one of them is a real cost, and one of them is actually better than the Vercel design.
1. Where things actually stand
Worth stating plainly, because it changes how expensive this decision is:
Corrected 2026-09-05. The first version of this section said
mainwas still the frozen Jekyll site and the Next.js rebuild had never been deployed. That was read off a localmainref captured when the session started and never re-fetched, and it was wrong on all three counts below. The conclusions survive — the CPU arithmetic in fact gets worse, not better — but the reasoning that got there was checking a stale pointer, and none of it should have been asserted without a fetch.
What is actually true:
mainalready carries the Next.js + Supabase rebuild —app/,supabase/,package.json,vercel.json. It was merged over PRs #105–#110.healthspanwire.comis served by GitHub Pages, frommain, via.github/workflows/pages.yml— which still runsjekyll build. So the domain shows the frozen Jekyll site even though the repository has moved on.- The ingest cron has been live on
mainfor some time, curling/api/ingeston a Vercel deployment every three hours via theINGEST_BASE_URLrepository variable. Eighteen runs, succeeding.
So two things are true at once, and conflating them is what produced the error:
the repository cut over months ago, the domain never did. This remains a
choice about which host to finish the cutover to — Pages already holds the
domain, so there is no DNS window with two live origins and the CNAME file
stays where it is instead of being deleted in Phase 8.
The Fluid Active CPU warning (2026-09-05)
Team aggreagators (Hobby) is at 3 h 2 m of its 4-hour Fluid Active CPU
allowance, and the stated remedy for exceeding it is pausing the team’s
projects — plural, so locreport.com goes down with anything else.
The usage dashboard splits it:
| Project | Period total | Share | Shape |
|---|---|---|---|
locreport |
2 h 16 m | 74.5% | Flat ~4–6 min/day, every day since Aug 8 |
healthspanwire |
46 m 32 s | 25.5% | Nothing until Sep 1, then 2 → 21 → 13 → 7 min/day |
The share column is the misleading one. LocReport’s 74.5% is a month of steady baseline; HealthspanWire’s 46 minutes were spent in five days, and on the peak day it was roughly 21 of the day’s 28 minutes — about three quarters of that day’s burn. It went from zero to the dominant line in under a week, which is what the “your site is growing” warning is pointing at.
Note the Vercel API does not list a healthspanwire project — list_projects
returns only locreport and get_project 404s on the slug — while the billing
dashboard attributes 46 m 32 s to it. The dashboard is authoritative for usage;
the project has presumably been deleted or is outside the API token’s scope.
Do not conclude from an empty project listing that nothing was spent.
Where those 46 minutes went (corrected): not preview deployments, as first
stated here. The 3-hourly ingest cron was calling /api/ingest on the Vercel
deployment — a route carrying maxDuration = 300 that fetches feeds, extracts
article text and calls OpenAI. Eight runs a day of that is the shape of the
spend, and the daily bars starting on Sep 1 line up with when it began pointing
at a deployment that answered.
That makes the case for moving stronger, not weaker. A cost driven by previews would fall away by itself once development stopped. A cost driven by the cron is permanent and grows with the feed list — and it is the one part of the system that had to move regardless, because a 300-second ceiling is a poor fit for a scraping job. Production would still add, on top:
- ISR re-renders across ~3,500 articles (
revalidate = 3600/86400) middleware.tson every non-asset request, each doing a SupabasegetUser()
That inverts the argument MIGRATION_PLAN.md makes for Vercel — “The Vercel
team (aggreagators, Hobby plan) already hosts LocReport, so the account,
billing posture and deploy conventions are proven” (§Hosting). What LocReport
actually proves is that one aggregator on a 3-hourly ingest cadence consumes
~57% of the allowance on its own, leaving no room for a second.
Two consequences, in order:
- Immediate: with ~58 minutes left and a recent burn of 11–28 min/day, the allowance is 2–5 days from exhaustion. The fastest fix is to stop deploying HealthspanWire to Vercel at all — pause the project or disconnect the git integration so branch pushes stop creating preview deployments. That is a two-minute action and it buys the time to do the rest properly.
- Structural: a static export contributes exactly zero Fluid Active CPU — not less, none, because there are no functions. The CPU-heavy half (Readability extraction, OpenAI calls, markdown rendering) moves to GitHub Actions, unmetered on a public repo.
Zeroing HealthspanWire is necessary but not sufficient: LocReport’s flat baseline alone is ~2 h 16 m against a 4-hour ceiling, so the team stays permanently past half its allowance with no headroom. Its ingest cron needs the same treatment, in its own repo. See §7.3.
2. What Vercel is doing in the current code
Inventoried from the tree, not from the plan:
| Capability | Where | Survives a static export? |
|---|---|---|
| Supabase session refresh | middleware.ts |
No — middleware is unsupported |
| 9 API route handlers | app/api/** (865 lines) |
No — no server at request time |
ISR (revalidate = 3600 / 86400) |
8 files | No — becomes build-time only |
Filter + pagination via searchParams |
app/(public)/articles/page.tsx |
No — no per-request render |
8 force-dynamic admin pages |
app/(public)/admin/** |
No |
| Server-side admin gate | lib/auth.ts, ADMIN_EMAIL |
No — moves to RLS |
| 24 redirect rules incl. 2 catch-alls | vercel.json |
No — no server redirects |
| 6 redirects | next.config.ts redirects() |
No — unsupported in export |
maxDuration = 300 on ingest |
app/api/ingest/route.ts |
n/a — job moves to Actions |
And, encouragingly, what is already static-safe:
- No
next/imageanywhere, so there is no image-optimisation dependency. - No Server Actions.
- No
cookies()/headers()in the public layout —components/AdminMenu.tsxdocuments that this was deliberately avoided. app/(public)/signals/[id]/page.tsxalready hasgenerateStaticParams.app/sitemap.ts,app/robots.tsandapp/(public)/feed.xml/route.tsall render from a plain GET with no request data, so Next emits them as static files on export.app/(auth)/login/page.tsxis already a client component usingsupabase.auth.signInWithPasswordin the browser.
The public tree is genuinely close to static already. The work is concentrated
in /admin, /api, and the redirects.
3. The replacement architecture
GitHub Actions (cron)
├── ingest every 3h → writes Supabase, then dispatches build
├── digest weekly → reads Supabase, sends via Resend
└── pages build on push + on dispatch → next build --export → Pages
Reader ──► GitHub Pages (static HTML, custom domain, free TLS)
│
└── browser ──► Supabase anon key (public-read RLS)
for /articles filters, pagination, counts
Editor ──► /admin (static shell) ──► Supabase Auth in the browser
│ │
└──────────────────────────┴──► Supabase directly, gated by RLS
Visitor subscribing ──► Supabase Edge Function `subscribe` ──► Resend
3.1 Hosting — GitHub Pages, static export
next.config.ts gains output: 'export' and images: { unoptimized: true },
and loses async redirects(). pages.yml builds Next instead of Jekyll.
Two files to add under public/ so they land in out/:
public/CNAME— the custom domain, currently at repo root for Jekyll.public/.nojekyll— insurance so nothing ever strips_next/, whose leading underscore Jekyll would otherwise eat.
GitHub Pages resolves extensionless paths against .html files, so Next’s
default export layout (out/articles/some-slug.html served at
/articles/some-slug) matches the URLs the site already publishes. Do not
turn on trailingSlash — it would change every canonical.
3.2 Ingest — a GitHub Actions job, not an HTTP endpoint
Status: done and merged. lib/ingest.ts (the run), scripts/ingest.ts
(the CLI), .github/workflows/ingest.yml (the schedule). The cron no longer
calls a deployment at all, so the largest single consumer of the Fluid
allowance is off it. The route survives as a 59-line
wrapper, down from 462, only because the admin panel’s “Run ingest” button still
posts to it.
/api/ingest was an HTTP endpoint for one reason: Vercel schedules work by
calling a URL. That cost a base URL the workflow had to know, a CRON_SECRET
kept identical in two places, a 300-second ceiling that MAX_ARTICLES = 18 was
chosen to fit inside, and a report rebuilt from JSON by an inline Python block
to be readable in the log. None of it survives the move.
The run itself moved unchanged. Nothing in lib/ingest.ts or anywhere in its
dependency chain imports from next/* — that is what lets one copy serve both
callers, and it needed one fix: lib/settings.ts imported createServiceClient
as a value purely to derive ReturnType, dragging next/headers into anything
that read a setting.
revalidatePath moved out to the callers as a changed flag on the result. On
a static host the same answer means “rebuild the site”, which is the workflow’s
business, not the run’s.
3.3 Publishing — rebuild instead of revalidate
lib/revalidate.ts sweeps the ISR cache after a write. With no server there is
no cache to sweep: the write has to trigger a build.
- Ingest ends with a
workflow_dispatch(orrepository_dispatch) of the Pages build. - An editor saving in
/adminfires the same dispatch, via a Supabase Edge Function holding a fine-grained PAT — a browser must not hold a token that can dispatch workflows.
Freshness changes from “stale for at most an hour, then revalidated on the next reader” to “live once the build finishes”, a few minutes after ingest or a save. For a publication on a 3-hour ingest cadence this is arguably the more honest model: what is on disk is what was published.
3.4 Admin — a client-side app against Supabase
Status: the database half is done and tested. See
supabase/migrations/0007_admin_without_a_server.sql, verified by
scripts/test_supabase.sh (24 assertions, all passing).
Every route under app/api/ is the same three steps: check the caller is the
admin, run one Supabase query with the service-role key, flush the ISR cache.
Postgres does the first two natively. The routes exist because Vercel gave us
somewhere to put them.
| Route | Replacement |
|---|---|
GET /api/me |
The browser already holds the session; read the email off it |
GET /api/stats |
Direct reads — lib/admin-stats.ts runs unchanged client-side |
GET /api/drafts, /api/drafts/[id] |
Direct reads, policy admin can manage drafts |
GET/POST /api/settings |
Direct, policies enforce the WRITABLE_SETTINGS allowlist |
GET/POST/PATCH /api/sources |
Direct, policy admin can manage rss sources |
PATCH/DELETE /api/articles/[id] |
Direct; image_url guarded by a CHECK constraint |
PATCH /api/drafts/[id] (approve) |
Needs a Postgres function — see below |
POST /api/ingest |
GitHub Actions job (§3.2) |
POST /api/admin/backfill-embeddings |
GitHub Actions job — holds the OpenAI key |
The security hole this closes
0001_baseline.sql grants writes on articles, signals and glossary to
auth.role() = 'authenticated' — that is any signed-in Supabase user, not the
admin. It has never mattered, because writes go through routes that call
getAdmin() first and the service-role key bypasses RLS anyway. The moment
the browser writes directly, that policy is the entire boundary. 0007 replaces
it with public.is_admin(), which compares the JWT email against
settings.admin_email and fails closed when unset.
Three server-side checks move into the schema with it:
ADMIN_EMAIL(lib/auth.ts) →public.is_admin(),SECURITY DEFINERso it can read the RLS-protectedsettingstable without recursion.WRITABLE_SETTINGS(lib/admin-stats.ts) → split select/insert/update policies with the four keys inline.admin_emailis deliberately absent, so an admin session cannot rewrite the value that decides who the admin is.parseImageUrlInput()(lib/url-safety.ts) →public.is_safe_image_url(), a SQL port of the SSRF guard, as aNOT VALIDCHECK constraint onarticlesanddrafts.NOT VALIDso it binds new writes without re-checking ~3,500 existing rows, one bad legacy value in which would abort the migration.
seen_urls is deliberately given no policy at all: only ingest touches it, and
ingest runs in Actions with the service-role key.
The trap: RLS denies silently
RLS does not refuse a SELECT, UPDATE or DELETE — it filters the rows the
statement can see. A fully-denied write returns success affecting zero rows,
which reaches the browser as 200 with an empty array, not as the 401 the API
routes returned. Only INSERT raises 42501.
The first version of the test suite caught exceptions and reported every denial
as “allowed” — all eleven RLS assertions passed while the policies did nothing.
The editors have the same exposure: ArticleEditor, DraftEditor,
SettingsForm and SourcesTable must each assert on returned rows
(.update(...).select() and check the length), or an editor sees “Saved” over a
write that never happened. That is the same silent-success failure
lib/revalidate.ts exists to fix, and it is the single most likely bug in this
migration.
Still to do
- Draft approval.
PATCH /api/drafts/[id]withapprovereads the draft, paginates every article slug to compute a unique one, inserts the article and updates the draft. Four statements that must not half-apply, so it belongs in aSECURITY DEFINERfunction —approve_draft(uuid)— called over RPC, not in a browser sequence. - The four editors rewired from
fetch('/api/...')to Supabase, with the row-count assertion above. /adminHTML becomes publicly fetchable. It renders nothing without a session, and the data is protected by RLS rather than by the page — normal for an SPA, but a change from a server-gated route.
3.5 Newsletter — Edge Functions + Resend
subscribers and digest_sends are correctly service-role-only
(0003_subscribers.sql: “Subscriber email addresses must never be reachable
through the anon key”). That must not be relaxed, so signup needs a server —
which is what Supabase Edge Functions are for. Three small Deno functions:
subscribe— validate, insertstatus='pending', send the confirm mail through Resend. Rate-limit by IP.confirm— flip toactiveonconfirm_token.manage— preferences and unsubscribe onmanage_token.
The weekly digest is a GitHub Actions cron: query Supabase, render, send through
Resend, write digest_sends. The existing idempotency design (skip anyone whose
last_sent_at falls in the period) already makes a re-run safe.
newsletter.md, newsletter-confirm.md and newsletter-welcome.md in the
Jekyll tree are the pages these replace; vercel.json already redirects
/newsletter/confirm → /subscribe/confirm, which becomes a static stub.
3.6 Redirects — the one real cost
This is the part where GitHub Pages is genuinely worse than Vercel.
vercel.json carries 20 explicit rules plus two catch-alls that cover the whole
archive:
{ "source": "/articles/:year(\\d{4})/:month(\\d{2})/:day(\\d{2})/:slug.html",
"destination": "/articles/:slug", "permanent": true }
Static hosting cannot issue a 301. The replacement is a build step that reads
articles.legacy_url from Supabase and writes one stub per legacy path into
out/:
<link rel="canonical" href="https://healthspanwire.com/articles/<slug>">
<meta http-equiv="refresh" content="0; url=/articles/<slug>">
Google treats an instant meta refresh as a permanent redirect, and the canonical link reinforces it — but it is weaker and slower to be honoured than a server 301, and this is ~3,500 indexed URLs of accumulated authority.
Two things soften it:
- It is a cutover choice, not a regression. Those URLs are live right now as real Jekyll pages, and the Vercel plan was going to 301 them for the first time anyway.
app/(public)/articles/[...slug]/page.tsxalready resolves legacy URLs from the database viagetArticleByLegacyUrl, so the stubs can be generated bygenerateStaticParamson the route that already knows about them, rather than by a separate script.
The six next.config.ts redirects (/monthly-reports, /saved, /watched)
become the same kind of stub — trivial, they are six files.
3.7 Filters and pagination — query from the browser
/articles?topic=nutrition&impact=3&sort=impact&page=2 has no server to render
it. But articles already has a public-read RLS policy and the anon key is
already NEXT_PUBLIC_, so the browser can run the identical query.
lib/articles.ts is nearly isomorphic already — listArticles(),
parseFilters(), selectArticleCards() need only a Supabase client that is not
createPublicClient() from lib/supabase/server.ts. Make /articles a client
component over the same functions and behaviour is preserved exactly, including
the total count.
To keep the archive crawlable, pre-render the surfaces that should be indexed and leave the rest to the client:
/articles(page 1, unfiltered) — static/articles/<pillar>× 8 — static, these routes already exist/articles/page/<n>— static, ~146 pages atPAGE_SIZE = 24- everything with a query string — client-rendered, and was never meant to be indexed
4. Limits you would be trading into
Leaving one free tier for another is only a win if the new ceilings are further away. They mostly are, with one to watch:
| Ceiling | Current load | Headroom | |
|---|---|---|---|
| Pages — site size | 1 GB | ~3,500 articles ≈ 150–350 MB of HTML | Fine now; ~2–3 years at 40 articles/week |
| Pages — bandwidth | 100 GB/month (soft) | Comfortable | Fine |
| Pages — builds | ~10/hour (soft) | 8/day from ingest, plus editor saves | Fine unless an editor saves in a burst |
| Actions minutes | Unlimited on public repos; 2,000/month private | ~8 builds/day × 4–6 min ≈ 1,400 min/month | Only viable if the repo stays public |
| Supabase DB | 500 MB | ~30 MB projected | Fine |
| Supabase Edge Functions | 500k invocations/month | Signup traffic only | Fine |
| Resend | 3,000/month, 100/day | Unchanged by the host | Same either way |
Two of these deserve attention:
- Repo visibility. At ~1,400 Actions minutes/month this only works free on a public repo. That is what it is today, but it makes it a constraint rather than a preference.
- Build time. A 3,500-page export where each page runs
getArticleBySlug+relatedArticlesis ~7,000 Supabase round trips, andrelatedArticlesfires up to three queries in its fallback chain. Left naive this is a 15-minute build. It needs a build-time cache: fetch all article cards once into memory and resolvegenerateStaticParams, related articles and listings from that. Budget this as real work, not a footnote.
5. What you lose
Being straight about it:
- Real 301s on ~3,500 legacy URLs (§3.6). The only genuine downgrade.
- Per-PR preview deployments. Pages gives you one site. CI can build a PR without deploying it, which catches breakage but is not a clickable preview.
- Server-enforced admin auth. RLS is a sound boundary, but it is a different one, and it has to be written correctly (§3.4).
- On-demand freshness. No
revalidatePath(); a change is live when a build finishes. - Vercel’s runtime logs and analytics. Actions logs replace the cron half; the request half has no equivalent, since there are no requests.
What you gain
- Ingest escapes the 300-second function ceiling and the public endpoint.
- One fewer vendor, one fewer set of environment variables to keep in sync.
- No Vercel Hobby non-commercial clause hanging over a publication.
healthspanwire.comnever leaves its current host, so there is no DNS cutover window with two live origins — whichMIGRATION_PLAN.md§Phase 8 itself flags as “the classic way to get an intermittently broken site.”
6. Work breakdown
| # | Area | Files | Size |
|---|---|---|---|
| 1 | Static export config | next.config.ts, public/CNAME, public/.nojekyll |
S |
| 2 | Pages workflow builds Next, not Jekyll | .github/workflows/pages.yml |
S |
| 3 | lib/ingest.ts, scripts/ingest.ts, ingest.yml |
done | |
| 4 | Build-time article cache | lib/articles.ts, generateStaticParams |
M |
| 5 | Legacy-redirect stub generation | articles/[...slug]/page.tsx or a build script |
M |
| 6 | /articles filters client-side |
articles/page.tsx, lib/articles.ts |
M |
| 7 | Admin → Supabase direct + RLS | 4 editors, 8 pages, 0007_admin_policies.sql |
L |
| 8 | Delete middleware + all API routes | middleware.ts, app/api/** |
S |
| 9 | Subscribe / confirm / manage Edge Functions | supabase/functions/** |
M |
| 10 | Weekly digest via Resend | .github/workflows/digest.yml, scripts/digest.ts |
M |
| 11 | Admin OpenAI actions → workflow_dispatch |
backfill-embeddings, monthly report | S |
| 12 | Retire the Jekyll tree | _posts, _layouts, _includes, Gemfile*, _config.yml |
S |
| 13 | Delete vercel.json |
S |
Items 3, 4 and 7 are where the time goes.
The order is not free
Item 1 cannot land before item 7. output: 'export' refuses to build a tree
containing middleware or dynamic route handlers, so the static export and the
deletion of app/api/** are the same commit — and the four admin editors still
fetch('/api/...'), so they have to be rewired onto Supabase first or that
commit ships a broken /admin.
3. ingest → Actions ✅ done
7. admin → Supabase direct ← blocks everything below
8. delete middleware + api/**
1. static export config
2. Pages builds Next ← the actual cutover
4/5/6. build cache, redirect stubs, client-side filters
The database half of item 7 is already done and tested (§3.4); what remains is
approve_draft() and the four editors.
7. Open decisions
Which Vercel limit is the problem?Answered: Fluid Active CPU (§1). Going static zeroes it. The Hobby non-commercial clause remains a separate reason to move a publication off that plan.- Does the repo stay public? §4 depends on it.
- LocReport needs the same fix, in its own repo. Zeroing HealthspanWire stops the spike but leaves the team at ~2 h 16 m of 4 h every period, purely on LocReport’s baseline. Moving its ingest cron off a Vercel function to a GitHub Actions job is a far smaller change than this document describes, and is what actually restores headroom. Second in order, not first.
- Meta-refresh stubs for the legacy archive — acceptable? This is the one answer that should not be assumed.
/adminas a public static shell — acceptable? If not, the admin stays server-rendered somewhere, and “GitHub Pages only” does not hold.