Skip to content
GitHub
Decisions

ADR-046: Alpha hosting — Render PaaS, single workspace, audience-aligned subdomains

Status: Accepted (2026-04-21) Supersedes: ADR-037 D1 and D2 (the runtime backend swaps from GCP Secret Manager + Workload Identity Federation to Render Environment Groups + GitHub-stored Render API key)

Context

The decision surface was hosting per deployable across the 0.3.0 architecture — API, workers, customer dashboard, staff operations console, internal Codex docs, public user docs, and scheduled jobs. Spike intake established calibrated criteria: ease of provisioning + management paramount given solo / small-team scope; scriptable IaC posture from day 1; reputation matters; US-headquartered providers only; single-PaaS preference (compromise as needed); cost mid-tier importance (bootstrapped, pre-revenue); OIDC nice-to-have; reversibility preserved via standard primitives. Time horizon: alpha (6–12 months) with explicit revisit triggers.

The spike was reset from a prior GCP-conditional framing. ADR-037 D1/D2 (GCP Secret Manager + WIF) were treated as open, not as anchors pointing at a hosting choice. Step 1 inventory established four runtime services + cron jobs as the deployable count. Step 2 hard-requirement filter eliminated request-scoped platforms (Cloud Run Services, App Runner), Python-incompatible platforms (Vercel, Cloudflare Workers, Supabase Edge Functions), non-US providers (Northflank, Koyeb, Scaleway), and reputation-disqualified options (Fly.io, Railway). A bias-check disqualified k8s-class platforms (GKE Autopilot, ECS Fargate) for being inappropriate to current team size. Step 3 produced six peer candidates; Step 4 selected Render with AWS Elastic Beanstalk as runner-up.

Supabase managed (ADR-002) confirmed stays. Bare-Postgres and self-hosted-Supabase alternatives were rejected: auth migration alone would consume engineering capacity better spent on Spectral itself, and self-hosting Supabase’s six-container stack defeats the team-size discipline.

Decision

D1 — Render as alpha PaaS substrate

Four runtime services + cron jobs in a single Render workspace. Render Virginia region (AWS us-east-1 shadowed) co-located with the Supabase project in us-east-1. Sub-ms latency on the Supabase hot path.

D2 — Audience-aligned subdomain layout

  • api.runspectral.com → API (FastAPI)
  • app.runspectral.com → Dashboard Start (customer-facing)
  • ops.runspectral.com → Operations Start (staff-facing, server-side auth-gated)
  • codex.runspectral.com → Codex (Astro static; ADR-048 D3 moves this to Cloudflare Pages with a Pages Function for staff auth)
  • docs.runspectral.com → docs-user (Astro static; ADR-048 D3 moves this to Cloudflare Pages, public)

D3 — TanStack Start as the frontend framework

Captured canonically in ADR-050. Dashboard and operations both authored with TanStack Start primitives (Router + loaders + route definitions). SPA mode at alpha; SSR-per-route reserved for cases that demand it. No Next.js; no @supabase/ssr for data.

D4 — Operations as its own deployable

Operations is its own deployable with its own subdomain, its own TanStack Start process, and its own auth boundary at the deployment level. The decision is captured canonically in ADR-047, which also retires ADR-009.

D5 — Two TanStack Start processes; docs on Cloudflare Pages

Dashboard Start serves app.; Operations Start serves ops.. Docs (codex., docs.) run on Cloudflare Pages — codex. is gated by a Pages Function for JWKS-local auth that mirrors the Operations Start Pattern A; docs. is public. The original D5 had Start services static-mount docs via Nitro middleware; that path was narrowed by ADR-048 D3.

D6 — Workers as Render Background Worker

First-class always-on primitive matching the LISTEN/NOTIFY + outbox + LangGraph workload. R1 smoke test (D17) verifies dedicated-5432 listener behavior + graceful SIGTERM.

D7 — API runs uvicorn directly

uvicorn $APP --host 0.0.0.0 --port $PORT --workers $WORKERS. No gunicorn wrapping; Render handles process supervision at the orchestrator layer. Worker count env-configurable per service tier.

D8 — Frontends render in SPA mode at alpha

This D-point names rendering mode, not framework. The framework is TanStack Start (per D3 above, captured canonically in ADR-050) — Vite is the underlying bundler within Start, not the framework. Default rendering mode is SPA; SSR-per-route is reserved for cases that demand it (per ADR-050 D2).

Authentication via @supabase/supabase-js PKCE flow; JWT carried in cookie scoped at Domain=runspectral.com for cross-subdomain session sharing across app., ops., codex.. Data queries always go SPA → Bearer JWT → FastAPI → psycopg → Postgres. Supabase JS used only for auth, not for queries.

D9 — Operations Start auth middleware: Pattern A (JWKS-local validation)

Uses @supabase/supabase-js getClaims() to validate JWT signature against Supabase JWKS, checks app_metadata.account_role === "operations". Contract test enforces parity with FastAPI’s auth check (per ADR-039 D4a JWKS-local). No internal HTTP per gated request; the staff console can serve its bundle even when API is restarting. Pattern reused at the edge in ADR-052 (Cloudflare Pages Function).

D10 — Single Render workspace, per-service billing

All four runtime services + cron in one workspace. Per-service billing accepted at alpha scale. Revisit if total Render cost exceeds $400/month sustained or service utilization patterns become uneven enough to warrant a true shared-capacity plan model.

D11 — Render Environment Groups replace GCP Secret Manager (supersedes ADR-037 D1)

Render Environment Groups carry runtime secrets. The provider-swap seam (ADR-037 D11) exercises exactly as designed; the swap was a target-function rewrite (push_gcp_secretpush_render_env_group) plus runtime-identity bootstrap. .env.example, prompt flow, rotation semantics, and cache format unchanged.

D12 — Render API key in GitHub secrets (supersedes ADR-037 D2)

A narrow-scoped Render API key (per-workspace, per-environment) lives in scoped GitHub Environments with quarterly rotation per ADR-062 D5. Documented soft regression on the no-static-service-tokens discipline; acceptable per intake calibration of OIDC as nice-to-have. Revisit if Render adds first-class GitHub OIDC federation, or if a credential-bearing-SaaS incident touches Render.

D13 — Build and dev orchestration

Build orchestration via turbo (build, typecheck, lint pipelines defined in turbo.json). Dev orchestration via a concurrently ladder: pnpm dev runs the full-local stack; per-surface scripts (pnpm dev:api, pnpm dev:dashboard, etc.) for focused work. Frontends default to localhost API in dev (not staging-proxy); local Supabase via supabase start is a prerequisite.

D14 — Supply-chain hardening: minimumReleaseAge: 10080 (7 days)

In pnpm-workspace.yaml. Quarantines newly-published packages. Cheap to adopt, mitigates supply-chain compromise risk in the JS dependency surface.

D15 — Supabase managed stays

ADR-002 confirmed. Used for Postgres, Auth (PKCE + JWKS), pgvector, local dev CLI, managed backups + PITR posture. Not used in production: Realtime (replaced by sse-starlette per ADR-034 D2), PostgREST (FastAPI handles all data), JS query client (browser uses FastAPI), Edge Functions, Storage at alpha, Studio.

D16 — Compute plan layout: prefer single shared compute substrate at alpha

With documented split triggers: workers’ memory consistently > 70% of allocated capacity → workers move to dedicated tier first; sustained noisy-neighbor effect on API latency → split immediately; multi-region requirement → re-architect.

D17 — Smoke-test R1 is a merge-gate for contract artifacts

Before any Render-specific artifacts land, verify: LISTEN/NOTIFY listener on Render Background Worker holds a stable connection to Supabase Postgres for 24 hours, with reconnect-after-restart matching ADR-044 D9; simulated SIGTERM completes outbox cursor commit + UNLISTEN within configured grace window; auth middleware on Operations Start returns correct decisions on valid + invalid + expired JWTs. ~4–6 hours of spike work. If R1 fails, this disposition reopens with AWS Elastic Beanstalk as the runner-up substrate.

D18 — Revisit triggers

Hard (reopen immediately): Render outage materially impacting alpha operations; LISTEN/NOTIFY behavior on Background Worker fails R1; Render acquired by entity triggering reputation concern; pricing changes that double cost or restructure billing.

Soft (evaluate without committing to move): Render workspace cost > $400/month sustained; multi-region requirement appears; sustained worker memory pressure exceeding tier capacity; first design partner SLA Render’s status-page history can’t credibly support.

Alternatives considered

AWS Elastic Beanstalk — runner-up. Same Supabase region win + hyperscaler reputation + first-class GitHub OIDC. Disqualified at alpha for 50% higher monthly cost ($150–200/month vs ~$100–125), more ops surface, lower IaC cleanliness vs render.yaml. Reactivates if Render falls through.

Azure App Service Plan. Daybreak-validated, true shared-capacity model, strong OIDC. Cross-cloud Azure↔AWS latency to Supabase is the disqualifier given critical-path Supabase region co-location.

DigitalOcean App Platform. Cheapest, clean primitives. DO’s own data centers (not AWS) disqualified on Supabase region co-location.

GCP App Engine Flexible. Structural parity with Azure ASP. Cross-cloud latency + higher per-instance cost rank it below ASP.

Heroku (Salesforce). Primitives fit, AWS-co-located. Disqualified by reputation caveat.

AWS ECS Fargate. Fit-disqualified for ops surface; appropriate at scale we don’t have.

GCP GKE Autopilot. Same fit-disqualification.

GCP Cloud Run Services. Technically disqualified for workers (request-scoped; no SIGTERM guarantee on memory kill).

AWS App Runner. Technically disqualified (request-scoped, same class).

Fly.io. Disqualified by reputation filter.

Railway. Borderline reputation/track-record; disqualified at alpha.

Self-host Supabase. Six-container ops burden defeats team-size discipline.

Bare Postgres + custom auth. Months of work to rebuild auth; reopens ADR-039.

Next.js + @supabase/ssr. Unnecessary SSR complexity for authenticated dashboards.

gunicorn with uvicorn workers. uvicorn alone is fine in modern container deployments where the orchestrator handles process supervision.

Consequences

  • Frontend Next.js scaffold becomes obsolete. apps/dashboard and apps/operations rebuild as TanStack Start projects (Vite + TanStack Router + TanStack Query).
  • apps/operations directory must be properly populated as a parallel-to-dashboard app.
  • ADR-037 D1 / D2 swap exercised. tools/provision/setup.sh becomes Render-native via push_render_env_group.
  • @supabase/supabase-js initialized with cookieOptions.domain = "runspectral.com" for cross-subdomain session sharing.
  • Operations Start gains server-side auth middleware (Pattern A: JWKS-local validation). Contract test enforces parity with FastAPI’s auth check.
  • Workers’ bursty memory profile shares compute pool with API at alpha. Documented split triggers govern when this becomes problematic.
  • Render API token in GitHub secrets is a documented soft regression on ADR-037 D2. Token narrow-scoped, rotatable, revisit-trigger documented.
  • Codex deploys as part of the docs-codex Pages cadence (per ADR-048 D3 refinement of TA-21 D5).
  • Cross-cloud latency to Supabase (would have been 3–15 ms on Azure/GCP) avoided. Render Virginia ↔ Supabase us-east-1 sub-ms on the hot path.
  • Cost envelope at alpha ~$100–125/month for 4 services + cron jobs + statics. Within bootstrapped pre-revenue tolerance.
  • ADR-048 (TA-19) and ADR-049 (TA-20) unblocked by this disposition.
  • ADR-052 (TA-22) becomes the next spike with Render established as the compute substrate.

References

  • ADR-002 — retired; original Supabase platform decision distilled into the addendum below
  • ADR-009 — retired and folded into ADR-047
  • ADR-065spectral.core admission discipline
  • ADR-037 — D1 and D2 superseded by this ADR; D11 swap exercised
  • ADR-039 — JWKS-local validation pattern
  • ADR-047 — ADR-009 supersession
  • ADR-048 — TA-19 deployment topology
  • ADR-049 — TA-20 container strategy
  • ADR-050 — TanStack Start adoption
  • ADR-052 — TA-22 edge / CDN / DNS
  • ADR-053 — TA-26 CD pipeline orchestration
  • TA-21 disposition — SPEC-324 comment 8e48c86a
  • TA-21 verification — SPEC-324 comment dbf7405b
  • TA-22 routing-mechanism refinement — SPEC-324 comment 25bfd2d8
  • TA-19 D3 docs-on-Pages refinement — SPEC-324 comment eb188b3f
  • docs/runbooks/hosting.md — operational runbook
  • Codex system-design/topology/infrastructure/hosting.mdx — close-pass new page
  • Codex system-design/topology/frontend-architecture.mdx — close-pass new page

Addendum: ADR-002 — Supabase for Database, Auth, and Infrastructure Services

ADR-002 (Accepted 2026-03-21; retired by this ADR) established Supabase as the platform substrate for Postgres, Auth (JWT + OAuth), Realtime, Storage, and pgvector, on the basis that a multi-tenant SaaS needed strong-consistency relational storage with first-class RLS support and that Supabase let a small team avoid bespoke auth + backups + local-parity tooling.

Why a future reader should know about ADR-002:

  • The Supabase platform commitment itself is preserved (D15 here) — this ADR does not move off Supabase; it reframes Supabase’s role.
  • Supabase is treated as a managed service of apps/api rather than as a standalone architectural decision: the API process owns the connection, RLS is the backstop (per ADR-033), and the frontend reaches data through apps/api rather than the Supabase JS client (per ADR-034).
  • Realtime is replaced by sse-starlette per ADR-034 D2; PostgREST is replaced by FastAPI; the JS query client is unused in production. The set of Supabase products actually in production is narrower than ADR-002’s original framing.
  • Auth specifics (PKCE flow, JWKS-local validation, scope/role taxonomy, session-var convention) are covered canonically by ADR-039.
  • pgvector commitment carries forward unchanged for embeddings (per ADR-038).

Git history at the commit retiring ADR-002 preserves the original text.