A two-question decision report for a solo operator · 2026-06-18
Keyless: Most third-party LLM/SaaS keys cannot be eliminated — for Anthropic, OpenRouter, Supabase, ElevenLabs, the transcription vendors and the rest, "keyless" realistically means move the key into a managed, rotated store, not make it vanish. The only true-keyless wins are GCP-side: the kb-service / conversion-worker secrets pulled by an attached Cloud Run service account, plus the two downloadable Google SA-key JSONs (Admin SDK, Drive) that workload-identity / domain-wide delegation can replace. The single architectural path to true-keyless LLM access is re-routing Claude/Gemini onto Bedrock / Vertex (or Anthropic WIF) — a real option, not a free switch.
OpenTofu: KEEP & CONSOLIDATE Keep it only for the live Cloudflare edge + authz root (highest blast-radius, hardest to hand-edit, and no alternative beats it for this five-vendor stack), but consolidate the three roots onto one R2 backend, narrow the CLAUDE.md mandate from "always" to "edge / DNS / Access / IAM only," and delete the dead GCP/Cloud-Run and stale auto-deploy code instead of expanding it.
Every runtime app secret falls into one of three honest buckets. The ceiling is set by the vendor (does it support workload identity?) and the platform (does it offer a managed store?). Railway has neither — so every Railway secret floors at hard-static.
Across the 49 scored secrets in the inventory (true config-only URLs included, counted in their floor bucket).
Read the shape, not the precision: only a handful of secrets are genuinely eliminable. The bulk are "store it properly and rotate it." The hard-static block is almost entirely Railway (life-app + life-bot) plus the one browser-inlined ElevenLabs key — i.e. platform-floored, not vendor-floored.
sk-ant-oat) or Bedrock/Vertex, and Gemini via Vertex ADC — all of which require a platform that can mint a trusted OIDC identity (GCP/AWS), which Railway cannot.
Best keyless surface in the fleet — under Cloud Run its 3 Secret-Manager secrets are already true-keyless via the attached SA, and the two Google SA-key JSONs are eliminable. But live prod is Railway, where all of it is plaintext = hard-static. Verdict hinges on topology; consolidate runtime on Cloud Run to capture the wins.
Cleanest case: its one Secret-Manager secret (read-only R2 key) is keyless via a single least-privilege SA binding; the rest are optional and just need moving off deploy-env plaintext into Secret Manager. Effectively done on the keyless front.
Nothing eliminable (Supabase/GitHub/OpenRouter, no WIF). All in encrypted Pages secrets — real upgrade is account-level Cloudflare Secrets Store. Prioritise SUPABASE_LIFE_SERVICE_ROLE_KEY + GITHUB_PROMOTE_PAT (consider a short-lived GitHub App token).
One urgent non-store fix: VITE_ELEVENLABS_API_KEY is compiled into the browser bundle — eliminate it via the existing server proxy + rotate now. Everything else → Secrets Store; the two cross-project service-roles are the rotation priorities.
No keyless path. Move per-Worker secrets to Secrets Store + migrate Supabase keys to sb_ format. Resolve the possibly-dead NEXT_PUBLIC_SUPABASE_ANON_KEY build-time path before touching it.
Already effectively keyless — holds no keys, tokens or Supabase creds; one non-secret backend URL, auth is the forwarded CF_Authorization cookie. The "nothing to do" exemplar.
External keys (Slack token+secret, OpenRouter) floor at hard-static on Railway. Practical move: OpenRouter provisioning-API + Slack token rotation, or a platform move for a real store. No DB/service-role creds keeps blast radius low.
Worst ceiling: the secret-holder is the Railway server — no store, no rotation, no WIF, so every secret is hard-static (2 service-roles, DB password, Anthropic, OpenRouter, Google AI, Deepgram, ElevenLabs, 2 Discord webhooks). Anthropic/Gemini could go keyless via WIF/Vertex, but only after moving this surface off Railway. Until then: disciplined manual rotation + sb_ migration + sealed vars.
External-vendor runtime secrets, bucketed. (golden-questions-manager & golden-prompt-tests hold only internal self-issued tokens — no external-vendor secrets, so nothing is bucketed for them.)
| Project | Secret · vendor | Bucket | Action | Eff. |
|---|---|---|---|---|
| kb-service | DATABASE_URL · Supabase | managed | Keep SA-pull on Cloud Run (app holds no cred); rotate the embedded DB password on schedule. | low |
| kb-service | R2_SECRET_ACCESS_KEY (write) · R2 | managed | Keep SA-pull; R2 has no WIF so rotate the S3 key (new → Secret Manager latest → revoke old). | low |
| kb-service | GOOGLE_OAUTH_CLIENT_SECRET · Google | managed | Static web-app OAuth secret — keep SA-pull, rotate periodically in Secret Manager. | low |
| kb-service | R2_ACCESS_KEY_ID · R2 | hard | Non-secret half of the token — leave; rotate alongside the secret half. | low |
| kb-service | GOOGLE_ADMIN_SDK_KEY (SA JSON) · Google | true_keyless | Eliminate the JSON: domain-wide delegation via the attached SA — the canonical thing WIF replaces. | med |
| kb-service | NOTION_TOKEN · Notion | managed | Off plaintext Railway env → Secret Manager via SA; optionally swap internal token for OAuth refresh-rotation. | med |
| kb-service | ANTHROPIC_API_KEY · Anthropic | managed | Near-term: managed store. True-keyless option: Anthropic WIF (jwt-bearer) or Vertex/Bedrock — architectural. | med |
| kb-service | OPENROUTER_API_KEY · OpenRouter | managed | No WIF — managed store + provisioning-API rotation (/api/v1/keys). Not keyless. | med |
| kb-service | ELEVENLABS_API_KEY · ElevenLabs | hard | Railway = no store/rotation/WIF → hard-static; rotate manually. SA-pull only if moved to Cloud Run. | low |
| kb-service | SPEECHMATICS_API_KEY · Speechmatics | hard | Hard-static on Railway; rotate manually. Secret Manager (Cloud Run) to lift it. | low |
| kb-service | MISTRAL_API_KEY (Voxtral) · Mistral | hard | Hard-static on Railway; rotate manually. | low |
| kb-service | ASSEMBLYAI_API_KEY · AssemblyAI | hard | Hard-static on Railway; mint short-lived streaming tokens server-side so the key never reaches clients. | low |
| kb-service | KB_DRIVE_SA_KEY (SA JSON) · Google | true_keyless | Highest-value keyless candidate — eliminate the JSON via attached-SA/DWD if run on Cloud Run; on bare Railway floors at hard-static. | med |
| kb-conv-worker | R2_SECRET_ACCESS_KEY (read-only) · R2 | managed | Cleanest case — nothing to change beyond a rotation schedule; lowest-risk secret in the fleet. | low |
| kb-conv-worker | R2_ACCESS_KEY_ID · R2 | hard | Non-secret half — leave; rotate with the secret half. | low |
| kb-conv-worker | OPENROUTER_API_KEY (escalation) · OpenRouter | managed | Off by default — when enabled, wire through Secret Manager via the SA; provisioning-API rotation. | low |
| kb-conv-worker | SUPABASE_REVIEW_UI_ANON_KEY · Supabase | managed | Anon RLS-read, low blast radius + fallback — into Secret Manager; rotate with sb_ migration. | low |
| life-hub | SUPABASE_REVIEW_UI_URL · Supabase | managed | Config URL (not a cred) — Secrets Store or demote to non-secret [vars]. | low |
| life-hub | SUPABASE_REVIEW_UI_ANON_KEY · Supabase | managed | Per-Pages secret → account-level Secrets Store; rotate via sb_ migration. | low |
| life-hub | GITHUB_TOKEN (read-only PAT) · GitHub | managed | → Secrets Store; consider GitHub App installation token (short-lived) — closer to keyless but App key still stored. | med |
| life-hub | GITHUB_PROMOTE_PAT (write) · GitHub | managed | Prioritise (write-capable) — Secrets Store + prefer a scoped GitHub App token over a long-lived write PAT. | med |
| life-hub | OPENROUTER_API_KEY · OpenRouter | managed | → Secrets Store; provisioning-API rotation. Not eliminable. | low |
| life-hub | SUPABASE_LIFE_URL · Supabase | managed | Config URL — Secrets Store or demote. | low |
| life-hub | SUPABASE_LIFE_SERVICE_ROLE_KEY · Supabase | managed | Prioritise (full RLS bypass) — Secrets Store + sb_secret_ rotation; scope to /api/life-bootstrap only. | med |
| review-ui | VITE_ELEVENLABS_API_KEY (browser) · ElevenLabs | hard | WORST POSTURE — fix first. Remove the browser-shipped key; route via the existing server proxy; rotate immediately (assume exposed). | med |
| review-ui | ELEVENLABS_API_KEY (server proxy) · ElevenLabs | managed | → Secrets Store; rotate with the browser key when retired. | low |
| review-ui | SUPABASE_REVIEW_UI_URL · Supabase | managed | Config URL — Secrets Store or demote. | low |
| review-ui | SUPABASE_REVIEW_UI_SERVICE_ROLE_KEY · Supabase | managed | Prioritise — Secrets Store + sb_secret_; already server-only behind gated proxy. | med |
| review-ui | SUPABASE_LIFE_APP_URL · Supabase | managed | Config URL — Secrets Store or demote. | low |
| review-ui | SUPABASE_LIFE_APP_SERVICE_ROLE_KEY · Supabase | managed | Prioritise — same life-app service-role held by life-hub + life-app server; rotate all three together. | med |
| stages-eval | SUPABASE_SERVICE_ROLE_KEY · Supabase | managed | Per-Worker secret → Secrets Store; sb_secret_ rotation. Already server-only + gated. | low |
| stages-eval | OPENROUTER_API_KEY · OpenRouter | managed | → Secrets Store; provisioning-API rotation. | low |
| stages-eval | NEXT_PUBLIC_SUPABASE_ANON_KEY · Supabase | managed | First resolve possibly-dead path; if live, move off build-time inline → Worker secret/Secrets Store. | low |
| stages-eval | SUPABASE_REVIEW_UI_URL · Supabase | managed | Config URL — Secrets Store or demote. Optional + fallback. | low |
| stages-eval | SUPABASE_REVIEW_UI_ANON_KEY · Supabase | managed | → Secrets Store; rotate with sb_. Low priority (anon, optional, fallback). | low |
| kb-reader | VITE_KB_API_BASE · internal | hard | Non-secret endpoint URL — no action. kb-reader holds zero runtime secrets. | low |
| life-app | SUPABASE_SERVICE_ROLE_KEY · Supabase | hard | Highest-risk + lowest-ceiling. Railway = no store/rotation/WIF. Mitigate: sb_secret_ + sealed var + manual rotation; real lift = platform move. | med |
| life-app | SUPABASE_URL · Supabase | hard | Config URL — leave; no rotation needed. | low |
| life-app | SUPABASE_DB_URL (DB password) · Supabase | hard | Deploy-time only — source from Keychain via secret get at deploy (not a stored Railway var); rotate password. | low |
| life-app | ANTHROPIC_API_KEY · Anthropic | hard | Hard-static on Railway; rotate manually. True-keyless only via WIF/Vertex/Bedrock + a platform move. | med |
| life-app | OPENROUTER_API_KEY · OpenRouter | hard | Hard-static on Railway; provisioning-API rotation is the only hygiene win. | low |
| life-app | GOOGLE_AI_API_KEY (Gemini) · Google AI | hard | Hard-static; rotate manually. True-keyless via Vertex ADC — needs GCP, not Railway. | med |
| life-app | DEEPGRAM_API_KEY · Deepgram | hard | Hard-static on Railway; rotate manually. | low |
| life-app | ELEVENLABS_API_KEY · ElevenLabs | hard | Hard-static on Railway; rotate manually. | low |
| life-app | DISCORD_WEBHOOK_URL · Discord | hard | Bearer-in-URL, no rotation API — regenerate-and-replace periodically. | low |
| life-app | DISCORD_WEBHOOK_REVIEWER_URL · Discord | hard | Same — regenerate periodically. | low |
CLAUDE.md mandates "always use OpenTofu." Reality: three fragmented roots, one of them mostly authored-but-never-applied fiction, with the most security-critical surface managed entirely by hand. The question isn't "IaC good or bad" — it's whether this tree earns its keep for one person.
The one load-bearing root: 6 DNS CNAMEs, 6 Pages domain attachments, 4 Access apps, 5 policies (incl. the *.life-labs.dev wildcard + IdP-gated employee policy), 1 zone — on a real R2 backend. But the whole GCP/Cloud-Run/Secret-Manager footprint + kb-service tunnel in the same root were never applied (tfvars still say REPLACE_WITH_GCP_PROJECT_ID).
3 Pages projects + 3 repos + 6 github_actions_secrets feeding an auto-deploy pipeline removed 2026-06-10. No backend block; state lives only on this Mac (infra/ isn't even a git repo). Pure negative-value maintenance.
4 Railway cron services (radar + drive). No backend block → single-copy local state. Introduces a community Railway provider whose siblings (jira-sync) are hand-made anyway.
Every live Access app, policy and service token that actually gates the tools was created by hand and explicitly never imported. The blast-radius-safety argument is currently aspirational for the resources that matter most.
tofu plan is the only dry-run diff that exists for it.!!! DRIFT !!! block records the exact stale resource + remediation IDs — drift is visible because tofu exists.ignore_changes=[include] on Policy B — structure in tofu, the live allowlist in the runtime.init -upgrade detonates it.ignore_changes pattern, and delete the dead GCP/Cloud-Run + stale auto-deploy code. The drift and split-state problems are arguments for finishing the job, not abandoning it.
No alternative beats OpenTofu for this five-vendor stack (CF + GCP + Railway + Supabase). It's the only option with mature first-party multi-vendor coverage, free self-hosted (R2) state, and no per-tool maintenance multiplier.
| Option | CF | Railway / Supabase / GCP | Verdict |
|---|---|---|---|
| Alchemy (TS-native) | Excellent, first-class | No first-party; you hand-roll resources | Pre-1.0, single-author. Only wins if the stack went CF-only. |
| SST v3 / Ion | Rough edges, Pages not first-class | Same Terraform-bridged providers | Heavier wrapper around the same providers; inherits the v4→v5 churn + org-relocation question. |
| Pulumi | Mature official provider | Community Railway + bridged Supabase | Lateral move; adds a state-backend dependency. No coverage win. |
| Polyglot CLIs (Wrangler + gcloud + railway + supabase) | Wrangler is best at Worker/Pages code | Each vendor's intended model | Strong complement, not a replacement — official CF guidance: Wrangler + Terraform as complements. |
The one genuine pain point — the CF provider v4→v5 rewrite — is inherited by every Terraform-bridged alternative (SST, Pulumi-Supabase), so switching engines doesn't escape it.
• CF DNS records (*.life-labs.dev CNAMEs)
• CF Access/Zero-Trust apps + policies + wildcard employee policy — import the hand-built live apps
• Pages custom-domain + Worker domain attachments
• The Google IdP wiring (keep as a tofu variable/output)
• Railway cron service definitions (structure only)
• GCP project IAM/config if/when the kb-service GCP stack is ever stood up
• The entire authored-but-never-applied GCP footprint (cloudrun.tf, 2 services, 2 SAs, 8 SM containers, 6 IAM bindings)
• opentofu-pages github_actions_secret (dead pipeline)
• kb-service CF tunnel + ingest-bypass .tf (superseded by hand-built apps)
• Worker/Pages code deploys → Wrangler
• App deploys + DB migrations → vendor CLIs
• Secret values + runtime-mutated data → out of state
State backend: Put all durable state on the existing R2 backend (life-ops-tofu-state) with distinct keys. Add an s3 backend block to opentofu-railway (key=infra/railway/terraform.tfstate) + tofu init -migrate-state; retire opentofu-pages rather than migrate it; git-init life-ops/infra immediately so .tf code + lockfiles have history (keep versions.override.tf gitignored — it has the R2 creds). Accept no native R2 lock — a discipline note ("never run two applies concurrently") suffices for a solo operator; revisit if a second person joins.
Split roots: Collapse to two roots on one backend. Merge opentofu-railway into the main root (or onto R2); retire opentofu-pages (state rm/destroy the dead Actions-secret resources, leave the 3 Pages projects wrangler-managed). Clear the recorded drift before the next apply (1 state rm + 2 imports at life-labs-dev.tf:338). Hard-pin the CF provider at 4.52.7 and schedule the v4→v5 migration as a deliberate, separate task using cf-terraforming — never an incidental init -upgrade.
Roughly 1–2 focused sessions, low risk. Ordered so the urgent security fix and the cheap hygiene wins come before the architectural options.
GITHUB_PROMOTE_PAT. RBAC + audit + rotation-without-redeploy. Effort: low–medium.ignore_changes pattern, then delete the dead GCP/Cloud-Run/tunnel code.sb_secret_ format and rotate all three holders together.sb_secret_) is the only available hygiene until a platform move.