Keyless secrets & OpenTofu — LIFE infra audit

A two-question decision report for a solo operator · 2026-06-18

Executive verdict

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.


1 · Keyless — how far can app secrets actually go?

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.

true_keyless app holds no credential (workload identity / SA-pull or eliminable SA-key)   managed_store_rotation value persists but lives in a managed store + rotates   hard_static plaintext, no store, no rotation, no WIF

Bucket distribution

Across the 49 scored secrets in the inventory (true config-only URLs included, counted in their floor bucket).

true_keyless
4
managed_store
26
hard_static
19

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.

The honest ceiling. "Keyless" is not "no secrets." For an SaaS key with no workload-identity story (OpenRouter, Supabase, ElevenLabs, Notion, Speechmatics, Mistral, AssemblyAI, Discord), the value must continue to exist somewhere — the win is putting it in Cloudflare Secrets Store (the Pages/Worker apps) or GCP Secret Manager via an attached SA (the Cloud Run apps), then rotating it. The only route to true-keyless LLM access is an architectural one: route Claude via Anthropic WIF (RFC 7523 jwt-bearer → short-lived 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.

Per-project rollup

kb-service

GCP Cloud Run (tf) / Railway (live) · partial_keyless

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.

kb-conversion-worker

GCP Cloud Run (tf) · can_go_keyless

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.

life-hub

Cloudflare Pages + Functions · store_only

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).

review-ui

Cloudflare Pages + Functions · store_only

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.

stages-of-meditation-eval

Cloudflare Worker (OpenNext) · store_only

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.

kb-reader

Cloudflare Pages (static SPA) · can_go_keyless

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.

life-bot

Railway · store_only*

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.

life-app

Railway + Supabase + CF Pages SPA · blocked_static

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.

Full per-secret table

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.)

ProjectSecret · vendorBucketActionEff.
kb-serviceDATABASE_URL · SupabasemanagedKeep SA-pull on Cloud Run (app holds no cred); rotate the embedded DB password on schedule.low
kb-serviceR2_SECRET_ACCESS_KEY (write) · R2managedKeep SA-pull; R2 has no WIF so rotate the S3 key (new → Secret Manager latest → revoke old).low
kb-serviceGOOGLE_OAUTH_CLIENT_SECRET · GooglemanagedStatic web-app OAuth secret — keep SA-pull, rotate periodically in Secret Manager.low
kb-serviceR2_ACCESS_KEY_ID · R2hardNon-secret half of the token — leave; rotate alongside the secret half.low
kb-serviceGOOGLE_ADMIN_SDK_KEY (SA JSON) · Googletrue_keylessEliminate the JSON: domain-wide delegation via the attached SA — the canonical thing WIF replaces.med
kb-serviceNOTION_TOKEN · NotionmanagedOff plaintext Railway env → Secret Manager via SA; optionally swap internal token for OAuth refresh-rotation.med
kb-serviceANTHROPIC_API_KEY · AnthropicmanagedNear-term: managed store. True-keyless option: Anthropic WIF (jwt-bearer) or Vertex/Bedrock — architectural.med
kb-serviceOPENROUTER_API_KEY · OpenRoutermanagedNo WIF — managed store + provisioning-API rotation (/api/v1/keys). Not keyless.med
kb-serviceELEVENLABS_API_KEY · ElevenLabshardRailway = no store/rotation/WIF → hard-static; rotate manually. SA-pull only if moved to Cloud Run.low
kb-serviceSPEECHMATICS_API_KEY · SpeechmaticshardHard-static on Railway; rotate manually. Secret Manager (Cloud Run) to lift it.low
kb-serviceMISTRAL_API_KEY (Voxtral) · MistralhardHard-static on Railway; rotate manually.low
kb-serviceASSEMBLYAI_API_KEY · AssemblyAIhardHard-static on Railway; mint short-lived streaming tokens server-side so the key never reaches clients.low
kb-serviceKB_DRIVE_SA_KEY (SA JSON) · Googletrue_keylessHighest-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-workerR2_SECRET_ACCESS_KEY (read-only) · R2managedCleanest case — nothing to change beyond a rotation schedule; lowest-risk secret in the fleet.low
kb-conv-workerR2_ACCESS_KEY_ID · R2hardNon-secret half — leave; rotate with the secret half.low
kb-conv-workerOPENROUTER_API_KEY (escalation) · OpenRoutermanagedOff by default — when enabled, wire through Secret Manager via the SA; provisioning-API rotation.low
kb-conv-workerSUPABASE_REVIEW_UI_ANON_KEY · SupabasemanagedAnon RLS-read, low blast radius + fallback — into Secret Manager; rotate with sb_ migration.low
life-hubSUPABASE_REVIEW_UI_URL · SupabasemanagedConfig URL (not a cred) — Secrets Store or demote to non-secret [vars].low
life-hubSUPABASE_REVIEW_UI_ANON_KEY · SupabasemanagedPer-Pages secret → account-level Secrets Store; rotate via sb_ migration.low
life-hubGITHUB_TOKEN (read-only PAT) · GitHubmanaged→ Secrets Store; consider GitHub App installation token (short-lived) — closer to keyless but App key still stored.med
life-hubGITHUB_PROMOTE_PAT (write) · GitHubmanagedPrioritise (write-capable) — Secrets Store + prefer a scoped GitHub App token over a long-lived write PAT.med
life-hubOPENROUTER_API_KEY · OpenRoutermanaged→ Secrets Store; provisioning-API rotation. Not eliminable.low
life-hubSUPABASE_LIFE_URL · SupabasemanagedConfig URL — Secrets Store or demote.low
life-hubSUPABASE_LIFE_SERVICE_ROLE_KEY · SupabasemanagedPrioritise (full RLS bypass) — Secrets Store + sb_secret_ rotation; scope to /api/life-bootstrap only.med
review-uiVITE_ELEVENLABS_API_KEY (browser) · ElevenLabshardWORST POSTURE — fix first. Remove the browser-shipped key; route via the existing server proxy; rotate immediately (assume exposed).med
review-uiELEVENLABS_API_KEY (server proxy) · ElevenLabsmanaged→ Secrets Store; rotate with the browser key when retired.low
review-uiSUPABASE_REVIEW_UI_URL · SupabasemanagedConfig URL — Secrets Store or demote.low
review-uiSUPABASE_REVIEW_UI_SERVICE_ROLE_KEY · SupabasemanagedPrioritise — Secrets Store + sb_secret_; already server-only behind gated proxy.med
review-uiSUPABASE_LIFE_APP_URL · SupabasemanagedConfig URL — Secrets Store or demote.low
review-uiSUPABASE_LIFE_APP_SERVICE_ROLE_KEY · SupabasemanagedPrioritise — same life-app service-role held by life-hub + life-app server; rotate all three together.med
stages-evalSUPABASE_SERVICE_ROLE_KEY · SupabasemanagedPer-Worker secret → Secrets Store; sb_secret_ rotation. Already server-only + gated.low
stages-evalOPENROUTER_API_KEY · OpenRoutermanaged→ Secrets Store; provisioning-API rotation.low
stages-evalNEXT_PUBLIC_SUPABASE_ANON_KEY · SupabasemanagedFirst resolve possibly-dead path; if live, move off build-time inline → Worker secret/Secrets Store.low
stages-evalSUPABASE_REVIEW_UI_URL · SupabasemanagedConfig URL — Secrets Store or demote. Optional + fallback.low
stages-evalSUPABASE_REVIEW_UI_ANON_KEY · Supabasemanaged→ Secrets Store; rotate with sb_. Low priority (anon, optional, fallback).low
kb-readerVITE_KB_API_BASE · internalhardNon-secret endpoint URL — no action. kb-reader holds zero runtime secrets.low
life-appSUPABASE_SERVICE_ROLE_KEY · SupabasehardHighest-risk + lowest-ceiling. Railway = no store/rotation/WIF. Mitigate: sb_secret_ + sealed var + manual rotation; real lift = platform move.med
life-appSUPABASE_URL · SupabasehardConfig URL — leave; no rotation needed.low
life-appSUPABASE_DB_URL (DB password) · SupabasehardDeploy-time only — source from Keychain via secret get at deploy (not a stored Railway var); rotate password.low
life-appANTHROPIC_API_KEY · AnthropichardHard-static on Railway; rotate manually. True-keyless only via WIF/Vertex/Bedrock + a platform move.med
life-appOPENROUTER_API_KEY · OpenRouterhardHard-static on Railway; provisioning-API rotation is the only hygiene win.low
life-appGOOGLE_AI_API_KEY (Gemini) · Google AIhardHard-static; rotate manually. True-keyless via Vertex ADC — needs GCP, not Railway.med
life-appDEEPGRAM_API_KEY · DeepgramhardHard-static on Railway; rotate manually.low
life-appELEVENLABS_API_KEY · ElevenLabshardHard-static on Railway; rotate manually.low
life-appDISCORD_WEBHOOK_URL · DiscordhardBearer-in-URL, no rotation API — regenerate-and-replace periodically.low
life-appDISCORD_WEBHOOK_REVIEWER_URL · DiscordhardSame — regenerate periodically.low

2 · OpenTofu — is it worth it?

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 reality on disk

terraform/ R2 remote

22 cloudflare resources LIVE

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).

opentofu-pages local, uncommitted

12 resources · LOCAL tfstate

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.

opentofu-railway local, uncommitted

4 resources · LOCAL tfstate

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.

Out-of-band (the dangerous gap)

100% click-ops / CF-API

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.

FOR / AGAINST scorecard

For keeping it

  • Blast-radius safety on the one live layer. The 22 R2-state resources are the single SSO chokepoint in front of every tool — one fat-fingered dashboard edit locks out (or silently exposes) every employee at once. tofu plan is the only dry-run diff that exists for it.
  • Drift visibility is the feature working. The in-code !!! DRIFT !!! block records the exact stale resource + remediation IDs — drift is visible because tofu exists.
  • Proven runtime/IaC hybrid via ignore_changes=[include] on Policy B — structure in tofu, the live allowlist in the runtime.
  • Load-bearing outputs — Access AUDs/app-IDs feed life-hub's wrangler.toml; tofu is already the SSOT for those IDs.
  • Bus-factor-of-one: codified edge+authz is the DR and succession plan.

Against, as it stands

  • Most of the tree is a drifting veneer. The entire GCP/Cloud-Run footprint was never applied and models kb-service on the wrong cloud (real runtime = Railway+Supabase+R2).
  • The security-critical surface is 100% out-of-band — every live Access app/policy/token created via CF API, never imported.
  • State fragmented & partly unbacked-up — 2 of 3 roots keep local, git-untracked tfstate on one disk. Lose the disk, lose the record.
  • A whole root maintains secrets for a dead pipeline (auto-deploy removed 2026-06-10).
  • Latent provider time-bomb — pinned on the last CF v4 (4.52.7) before the breaking v5 rewrite; an incidental init -upgrade detonates it.
  • Change frequency too low to amortise the per-apply tax for one person; the runbooks, not the state, are what keep this reproducible today.
Verdict: KEEP & CONSOLIDATE — keep OpenTofu only for the live Cloudflare edge+authz root; consolidate the three roots onto one R2 backend, import the hand-built Access apps using the proven 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.

Alternatives — does anything beat 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.

OptionCFRailway / Supabase / GCPVerdict
Alchemy (TS-native)Excellent, first-classNo first-party; you hand-roll resourcesPre-1.0, single-author. Only wins if the stack went CF-only.
SST v3 / IonRough edges, Pages not first-classSame Terraform-bridged providersHeavier wrapper around the same providers; inherits the v4→v5 churn + org-relocation question.
PulumiMature official providerCommunity Railway + bridged SupabaseLateral move; adds a state-backend dependency. No coverage win.
Polyglot CLIs (Wrangler + gcloud + railway + supabase)Wrangler is best at Worker/Pages codeEach vendor's intended modelStrong 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.

Recommended scope vs. what to drop

Keep in OpenTofu

• 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

Drop / move out

• 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

Concrete state-backend & split-roots fixes

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.


3 · Roadmap — low-effort / high-value first

Roughly 1–2 focused sessions, low risk. Ordered so the urgent security fix and the cheap hygiene wins come before the architectural options.

  1. Kill the browser-shipped ElevenLabs key (review-ui). The single worst posture in the fleet — route through the existing server proxy, then rotate immediately (assume exposed). Effort: medium · the only true emergency here.
  2. Move Cloudflare app secrets to Secrets Store. life-hub / review-ui / stages-eval: migrate per-Pages/Worker secrets to the account-level store, prioritising the service-roles + GITHUB_PROMOTE_PAT. RBAC + audit + rotation-without-redeploy. Effort: low–medium.
  3. Tofu state hygiene (½ day). git-init infra/, add the R2 backend to opentofu-railway + migrate-state, retire opentofu-pages Actions secrets, clear the wheel-benchmark drift (1 state rm + 2 imports), hard-pin CF provider at 4.52.7.
  4. Import the hand-built live Access apps (½–1 day). The actual value-add — bring the 2–4 click-ops Access apps/policies/tokens under tofu using the Policy-B ignore_changes pattern, then delete the dead GCP/Cloud-Run/tunnel code.
  5. Coordinate the shared service-role rotation. The life-app prod service-role is held by review-ui + life-hub + life-app server — migrate to sb_secret_ format and rotate all three holders together.
  6. Rewrite CLAUDE.md's IaC mandate. From "always use OpenTofu" → "use OpenTofu for durable plumbing — CF DNS, CF Access/Zero-Trust, IAM, project config; Wrangler owns Worker/Pages deploys; vendor CLIs own app deploys + migrations."
  7. Schedule manual rotation for the Railway hard-static keys. life-app + life-bot floor at hard-static — a recurring rotation cadence (OpenRouter provisioning API, Slack token, Discord webhook regen, sb_secret_) is the only available hygiene until a platform move.
  8. (Later, deliberate) The true-keyless architectural moves. Eliminate the two Google SA-key JSONs (Admin SDK, Drive) via attached-SA/DWD on Cloud Run; evaluate routing Claude via Anthropic WIF/Bedrock/Vertex and Gemini via Vertex ADC — the only path to true-keyless LLM access, gated on a platform move off Railway. Also: the standalone CF v4→v5 provider migration.