Running HealthspanWire on GitHub + Supabase + Resend

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 main was still the frozen Jekyll site and the Next.js rebuild had never been deployed. That was read off a local main ref 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:

  • main already carries the Next.js + Supabase rebuildapp/, supabase/, package.json, vercel.json. It was merged over PRs #105–#110.
  • healthspanwire.com is served by GitHub Pages, from main, via .github/workflows/pages.yml — which still runs jekyll build. So the domain shows the frozen Jekyll site even though the repository has moved on.
  • The ingest cron has been live on main for some time, curling /api/ingest on a Vercel deployment every three hours via the INGEST_BASE_URL repository 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.ts on every non-asset request, each doing a Supabase getUser()

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:

  1. 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.
  2. 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/image anywhere, so there is no image-optimisation dependency.
  • No Server Actions.
  • No cookies() / headers() in the public layout — components/AdminMenu.tsx documents that this was deliberately avoided.
  • app/(public)/signals/[id]/page.tsx already has generateStaticParams.
  • app/sitemap.ts, app/robots.ts and app/(public)/feed.xml/route.ts all render from a plain GET with no request data, so Next emits them as static files on export.
  • app/(auth)/login/page.tsx is already a client component using supabase.auth.signInWithPassword in 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 (or repository_dispatch) of the Pages build.
  • An editor saving in /admin fires 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 DEFINER so it can read the RLS-protected settings table without recursion.
  • WRITABLE_SETTINGS (lib/admin-stats.ts) → split select/insert/update policies with the four keys inline. admin_email is 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 a NOT VALID CHECK constraint on articles and drafts. NOT VALID so 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] with approve reads 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 a SECURITY DEFINER function — 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.
  • /admin HTML 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, insert status='pending', send the confirm mail through Resend. Rate-limit by IP.
  • confirm — flip to active on confirm_token.
  • manage — preferences and unsubscribe on manage_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.tsx already resolves legacy URLs from the database via getArticleByLegacyUrl, so the stubs can be generated by generateStaticParams on 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 at PAGE_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 + relatedArticles is ~7,000 Supabase round trips, and relatedArticles fires 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 resolve generateStaticParams, related articles and listings from that. Budget this as real work, not a footnote.

5. What you lose

Being straight about it:

  1. Real 301s on ~3,500 legacy URLs (§3.6). The only genuine downgrade.
  2. 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.
  3. Server-enforced admin auth. RLS is a sound boundary, but it is a different one, and it has to be written correctly (§3.4).
  4. On-demand freshness. No revalidatePath(); a change is live when a build finishes.
  5. 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

  1. Ingest escapes the 300-second function ceiling and the public endpoint.
  2. One fewer vendor, one fewer set of environment variables to keep in sync.
  3. No Vercel Hobby non-commercial clause hanging over a publication.
  4. healthspanwire.com never leaves its current host, so there is no DNS cutover window with two live origins — which MIGRATION_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 Ingest route → Actions script 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

  1. 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.
  2. Does the repo stay public? §4 depends on it.
  3. 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.
  4. Meta-refresh stubs for the legacy archive — acceptable? This is the one answer that should not be assumed.
  5. /admin as a public static shell — acceptable? If not, the admin stays server-rendered somewhere, and “GitHub Pages only” does not hold.