Skip to content

GCP Backend, WorkOS, and Supabase Exit

  • Status: Approved for implementation
  • Date: 2026-08-07
  • Target branch: dev
  • Production branch: main

Problem Statement

FPC currently depends on Railway for API compute and Supabase for PostgreSQL, authentication, and object storage. API compute cannot meet the desired scale-to-zero model, the database and authentication provider control too much of the application architecture, scheduled work runs inside an HTTP process that may not exist, and deployment is not managed through the desired GCP and Terraform workflow.

The migration must keep the product usable after every merged phase, stay within an ideal USD 20 monthly development-plus-production baseline, keep the database private, and end with the web and Capacitor app using WorkOS and the Cloud Run API.

Approved Solution

Implement the decisions in:

The functional migration proceeds from infrastructure to runtime, persistence, external adapters, authentication, background work, and finally the app. Railway and the required Supabase authentication and database capabilities remain available until their replacements are verified. Temporary compatibility adapters are removed in the last phase.

Frontend Hosting Amendment: Vercel to Cloudflare Pages

This plan originally invariant-locked the frontend to Vercel and excluded moving away from it. ADR 0015 supersedes that clause of ADR 0007 and voids the corresponding exclusion below, for reasons this plan restates because they change cost and hosting facts elsewhere in it:

  • Vercel's Hobby plan does not support deployment from a private repository owned by a GitHub organization, which feelproclub/fpc is, and its Fair Use Guidelines restrict Hobby to non-commercial use.
  • The compliant tier, Vercel Pro, costs USD 20 per user per month on its own, which alone exceeds this plan's ideal USD 20 monthly development-plus-production baseline before any other resource is counted.
  • app.feelproclub.com is unchanged, so this amendment carries no CORS, WorkOS redirect, or API contract change. Production allowed_origins in infra/environments/prod/main.tf already reads https://app.feelproclub.com and needs no edit.
  • The domain's DNS was hosted at Vercel rather than at its registrar (GoDaddy), so leaving Vercel required moving DNS regardless of destination. DNS now lives at Cloudflare alongside the Pages project.

See Cloudflare web setup for the operator runbook that brings the zone, DNS, Pages project, and Email Routing into this state.

Session and Branch Contract

Every implementation phase is a separate sequential session and feature branch.

  1. Start only after the preceding phase is merged into dev and required checks are green.
  2. Update local dev and create the phase's feature branch from it. The branch prefix identifies the agent that ran the session (codex/, claude/); the phase name after the prefix is what matters. Record the branch actually used in the status table.
  3. Give the session this migration plan, the referenced ADRs, and only the phase assigned to that branch.
  4. Use the repository's fpc-implement workflow for implementation and fpc-code-review for independent review.
  5. Follow the repository delivery order: implement, generate tests, simplify, review, validate old and new tests, perform UI validation when applicable, and perform a security review.
  6. Keep each commit small, conventional, and independently understandable. Do not mix later phases into the current branch.
  7. Update this plan's status table and evidence for the completed phase in the same branch.
  8. Open a pull request to dev. Merge only after checks, review, security findings, and phase acceptance criteria pass.
  9. Do not stack the next phase on an unmerged branch. Do not merge feature branches directly to main.
  10. Promote dev to main only at an explicit production milestone through a separate reviewed pull request.

If a phase discovers that an accepted ADR must change, stop implementation and propose a superseding ADR. Do not silently change the architecture inside a feature branch.

Status

PhaseBranchStatusDepends onEvidence
0codex/gcp-migration-decisionsComplete-ADRs and this plan
1claude/gcp-backend-migration-phase-1-ef18c6CompletePhase 0See Phase 1 evidence
2claude/gcp-phase-2-apply-fixesCompletePhase 1Merged to dev as 9dc3abf (PR #23); see Phase 2 evidence
3codex/gcp-api-runtimeCompletePhase 2Merged to dev through PR #26; see Phase 3 evidence
4codex/gcp-storageCompletePhase 3Merged to dev through PRs #48 and #50; see Phase 4 evidence
5claude/workos-api-authIn progressPhase 4Implementation validated locally; live rollout outstanding. See Phase 5 evidence
6codex/gcp-background-jobsPendingPhase 5-
7codex/workos-app-cutoverPendingPhase 6-
8codex/retire-supabase-and-railwayPendingPhase 7 soak-

Global Invariants

Every phase must preserve these properties:

  • dev is deployable and testable after merge.
  • main remains the production source of truth.
  • app.feelproclub.com remains the production origin. Cloudflare Pages serves it; see ADR 0015.
  • Production traffic remains on the current stable path until an explicit cutover.
  • The database never gains a public IP.
  • GitHub and Cloud Run never use committed service-account keys.
  • Secret payloads do not enter Terraform state, logs, plans, artifacts, or the repository.
  • Protected operations enforce authorization in the API, not only in the UI.
  • Shared contracts are implemented before their API and App consumers.
  • No phase adds ECS, EKS, GKE, GCE instances, or a permanently running cluster.
  • Provider-specific behavior stays behind a deep module or adapter when a production and test implementation genuinely vary.
  • Temporary compatibility adapters have metrics, tests, and an explicit deletion phase.
  • Actual and forecast monthly cost is reviewed before merging any new always-on resource.

Phase 0 - Decision Baseline

Branch: codex/gcp-migration-decisions

Objective

Merge the accepted ADRs, research evidence, and this execution plan into dev before infrastructure work starts. Current architecture documentation must continue to describe Railway and Supabase until implementation changes ship.

Commits

  1. Add the GCP backend-platform ADR.
  2. Add the WorkOS and application-owned-identity ADR.
  3. Add the monorepo Terraform ADR.
  4. Add the phased migration plan and session contract.
  5. Add documentation navigation and changelog entries.

Acceptance Gate

  • ADR index and navigation expose all three decisions.
  • The migration plan has an owner, branch per phase, dependency order, acceptance gates, rollback guidance, and explicit exclusions.
  • Documentation formatting and VitePress build pass.
  • No living architecture document claims the migration has already shipped.

Phase 1 - Terraform Bootstrap and Delivery Identity

Branch: claude/gcp-backend-migration-phase-1-ef18c6

Objective

Create the non-application baseline required to manage GCP safely: Terraform structure, project bootstrap, remote state, Workload Identity Federation, cost controls, and infrastructure change detection.

Commit Sequence

  1. Add the root infrastructure workspace with its instructions, formatting rules, version constraints, and ignore rules.
  2. Add the shared environment module's initial interface with validation-only tests and no application resources.
  3. Add explicit development and production roots with separate project inputs and backends.
  4. Add bootstrap roots for versioned state buckets, required APIs, GitHub federation, and narrowly scoped planning identities.
  5. Add one-time bootstrap and state-migration instructions, including recovery and break-glass ownership.
  6. Extend change detection so shared infrastructure changes select both environments and root-specific changes select only that environment.
  7. Add infrastructure CI for formatting, backend-free initialization, validation, tests, security scanning, and remote plans.
  8. Add protected GitHub Environment expectations: development apply from dev, production apply from main with approval.
  9. Add budgets and alert thresholds without encoding notification secrets in Terraform.

Tests and Evidence

  • Terraform formatting, validation, and non-provisioning tests pass for the shared module and both roots.
  • Change-detector tests cover shared module, development root, production root, bootstrap, workflow, rename, and deletion cases.
  • Workload Identity Federation can obtain short-lived credentials from the intended repository and branch claims.
  • State buckets reject public access, retain version history, and lock concurrent operations.
  • No service-account key or secret payload is created.

Rollback

Bootstrap resources are removed only through reviewed Terraform operations after confirming no later phase depends on their state or identities. State buckets are retained during rollback unless their state has been migrated and independently backed up.

Phase 1 evidence

Terraform configuration lives in infra/, following ADR 0009: a deep fpc-environment module, a shared fpc-bootstrap module, and thin explicit roots for each environment. The environment module declares its full interface with validation and no application resources.

Verified in this branch, with no GCP account and no cost:

  • terraform fmt -check -recursive infra is clean.
  • All six roots and modules initialize with -backend=false -lockfile=readonly and validate, which also proves the committed provider lock files are current for linux_amd64.
  • 31 non-provisioning terraform test runs pass across both modules using mock_provider. They cover the interface, the production safeguards the module refuses to relax, and every validation rule.
  • 18 change-detector tests pass, covering shared-module, development-root, production-root, bootstrap, workflow, rename, and deletion cases.
  • Trivy and Checkov both report no findings. Two real issues surfaced and were fixed rather than suppressed: project-wide roles/iam.serviceAccountUser was removed from the delivery identity, and Cloud Audit Logs now record data access on Cloud Storage. Three remaining suppressions carry inline reasons.
  • No service-account key, secret payload, billing account identifier, or alert recipient address is created or committed. Both are injected through TF_VAR_*.

Verified against real GCP after the development bootstrap:

  • The development environment applied 33 resources in feelproclub-dev, and an immediate second plan was exactly empty.
  • Bootstrap state migrated from the local backend into gs://feelproclub-dev-tfstate/bootstrap.
  • The state bucket enforces public access prevention and uniform bucket-level access, retains version history, and is neither force-destroyable nor destroyable by an ordinary Terraform run.
  • GitHub Actions exchanged its OIDC token for short-lived credentials, assumed the development planning identity, and reached Cloud Storage. No service-account key exists.
  • The budget alert recipient does not appear in plan output.

Two defects surfaced only when the configuration met a real project, and neither was reachable by local validation, mock_provider tests, or the security scanners:

  • The bootstrap roots run on a human's Application Default Credentials, which carry no quota project, so billing-account-scoped calls were attributed to a shared Google project where the API is disabled. Fixed with user_project_override and billing_project in committed configuration.
  • The GCS backend creates a state file the first time it sees a workspace. That write is one the read-only planning identity deliberately cannot perform, so an operator must seed each environment's state once. The runbook now names this explicitly.

Production was bootstrapped identically: 33 resources in feelproclub-prod, state migrated, second plan clean. Both environments' plan jobs now authenticate through federation and produce plans in CI.

A third defect surfaced only against real infrastructure, and it changed a design decision rather than a line of code. The planning identity was specified as read-only. The GCS backend acquires a state lock during terraform init, and -lock=false does not prevent it, so a read-only identity cannot plan at all. The identity now holds roles/storage.objectUser on its own state bucket. It remains confined to that one bucket and to roles/viewer on the project, so it cannot change infrastructure; damaging state is recoverable from versioning and soft delete. The README and the module test were corrected to state this rather than the original intent.

Phase 1 provisions no always-on resource. State buckets, federation, service accounts, budgets, and API enablement are free or sub-cent at FPC's scale; the first meaningful spend arrives with Cloud SQL in Phase 2.

Two details differ from ADR 0007, which is accepted and therefore immutable. Both are naming and placement rather than architecture, so they are recorded here instead of through a superseding ADR:

  • Region is us-east1, not us-central1. Both are Cloud Run Tier 1 regions, so there is no price difference for compute, and us-east1 is materially closer to the expected user base. The exact Cloud SQL regional rate is still to be confirmed before Phase 2 creates the instance, because a Cloud SQL region cannot be changed afterwards.
  • Projects are feelproclub-dev and feelproclub-prod, not fpc-dev and fpc-prod. Project IDs are globally unique and the short forms were taken. The longer identifier also keeps the derived bucket names unique, since the module builds them from the project ID. Resource names are unaffected: they derive from the environment, so the fpc-dev and fpc-prod prefixes remain.

Two deviations from the commit sequence are recorded deliberately rather than left for a reviewer to discover:

  • Adopting the pre-commit framework reaches beyond infra/. It adds secret scanning and repository-wide safety hooks that run on every commit, not only Terraform ones. This was an explicit request during the phase, accepted because Phase 1 is where the "no credential ever reaches the repository" invariant is established and a machine check is worth more than a convention.
  • The planning identity is narrowly scoped in permission but not in assumability. Pull request tokens carry no branch claim, so any workflow run in this repository can assume it; it is made read-only and plans run with -lock=false instead. The apply identity remains bound to repository and branch together.

Phase 2 - GCP Platform Baseline

Branch: claude/gcp-migration-phase-2-b6cab6 (merged to dev), continued on claude/gcp-phase-2-apply-fixes to fix three defects a real apply surfaced

Objective

Provision a complete but not yet traffic-serving development backend environment through the deep Terraform module. Production receives a reviewed plan but is not applied until the production milestone.

Commit Sequence

  1. Enable the required GCP service interfaces through Terraform.
  2. Add the VPC, dedicated serverless subnet, Private Services Access, and restricted egress policy.
  3. Add private Cloud SQL with the approved shared-core size, backups, retention, deletion protection policy, and no public IP.
  4. Add runtime and migration workload identities, Cloud SQL IAM users, and least-privilege database-role bootstrap instructions.
  5. Add public-assets and private-media buckets with public-access prevention appropriate to each bucket, CORS, lifecycle rules, and IAM.
  6. Add Artifact Registry and image-retention rules.
  7. Add Secret Manager containers and workload access bindings without adding secret values.
  8. Add the Cloud Run API definition and Cloud SQL Auth Proxy sidecar configuration with a placeholder image or disabled traffic path.
  9. Add the migration and scheduled-job definitions with separate identities but no production schedule activation.
  10. Add development database start/stop operations that cannot target production, and exclude activation state from Terraform drift reconciliation.
  11. Add cost outputs and document expected fixed and variable costs.

Tests and Evidence

  • Development apply is idempotent and a second plan is empty except documented operational fields.
  • Cloud SQL has only a private address.
  • The runtime identity can connect through the proxy but cannot change schema.
  • The migration identity can apply schema changes but is not used by the HTTP runtime.
  • Private media cannot be read anonymously; public logos can be read but not written anonymously.
  • Production plan contains only expected resources and remains unapplied.
  • Forecast cost remains within the approved MVP envelope.

Rollback

No production traffic depends on this phase. Disable Cloud Run traffic, stop development Cloud SQL, and use Terraform to remove replaceable development resources while preserving state and audit evidence.

Phase 2 evidence

This phase attaches fpc-environment's application resources: workload API enablement, a custom VPC with a dedicated serverless subnet and Private Services Access, default-deny egress, private Cloud SQL, four workload identities, both storage buckets, Artifact Registry, Secret Manager containers, the Cloud Run API service, both Cloud Run jobs, and a paused scheduler. The originating pull request (branch claude/gcp-migration-phase-2-b6cab6) exposed three defects during a real development apply; they were fixed on the follow-up branch, claude/gcp-phase-2-apply-fixes. The completed follow-up merged to dev as 9dc3abf (PR #23). Phase 2 is complete; its Cloud Run service remains a non-production platform shell until Phase 3's runtime acceptance gate passes.

Verified with no GCP account and no cost (both this branch and its b6cab6 predecessor):

  • terraform fmt -check -recursive infra is clean.
  • Every root and module initializes with -backend=false -lockfile=readonly and validates.
  • 85 non-provisioning terraform test runs pass across both modules using mock_provider — 71 in fpc-environment (interface, validation, database, storage, secrets, and runtime coverage) and 14 in fpc-bootstrap (including the fenced projectIamAdmin condition, the narrowed plan-identity role set, and the new compute.securityAdmin assertion below).
  • No google_secret_manager_secret_version, service-account key, or secret payload exists anywhere under infra/.

Three defects surfaced only when the configuration met a real project, and none was reachable by local validation, mock_provider tests, or the security scanners:

  • roles/compute.networkAdmin cannot create firewall rules. It grants only compute.firewalls.get and .list — verified against the live role definition — so the apply identity's egress firewalls (network.tf) failed with a permission error. Fixed by adding roles/compute.securityAdmin to the apply identity in fpc-bootstrap/identities.tf, with a comment recording why networkAdmin alone is insufficient so it is not "simplified" away again, and a non-vacuous test asserting the role is present.
  • Cloud SQL cannot be created in a stopped state. activation_policy = "NEVER" is rejected at creation with "This operation is not valid for this instance" — a known provider limitation, hashicorp/terraform-provider-google#8874 — which made the database_start_stopped input undeliverable for its one documented purpose. Rather than keep an input that cannot do what it says, it was removed entirely: database.tf now hardcodes activation_policy = "ALWAYS" with a comment explaining the limitation, and the ignore_changes on settings[0].activation_policy is unchanged, so an operator can still stop the instance afterward without Terraform reconciling it back. The operational intent is unchanged — development and pre-launch production still end up stopped — it now happens by creating the instance running and stopping it afterward (infra/scripts/db-power.sh for development; by hand with gcloud for pre-launch production, since that script refuses prod unconditionally) rather than by a Terraform input. ADR 0007's "Production database activation" section is corrected accordingly.
  • The public-assets bucket's allUsers grant is blocked by an organization policy. The organization enforces Domain Restricted Sharing (constraints/iam.allowedPolicyMemberDomains), which rejects an allUsers binding with HTTP 412 ("One or more users named in the policy do not belong to a permitted customer") regardless of what Terraform declares. Fixed by removing the google_storage_bucket_iam_member.public_assets_public_read resource and its accompanying Checkov and Trivy suppressions from storage.tf; public_access_prevention stays "inherited" so the bucket remains ready for a public grant without recreating it. The owner deferred the public grant to Phase 4, where team logos are actually served, to decide there between an org-policy exception and signed URLs. tests/storage.tftest.hcl's "no public bucket member" assertion now covers both buckets instead of carving out an exception for this one.

The follow-up branch's acceptance gate completed before its merge to dev as 9dc3abf (PR #23). Phase 3 changes the runtime definitions and therefore requires its own Terraform validation, development deployment, and smoke-test evidence; Phase 2 evidence must not be treated as proof that the API can reach Cloud SQL.

Eleven deviations and decisions are recorded here plainly rather than left for a reviewer to discover:

  • The branch actually used is claude/gcp-migration-phase-2-b6cab6, not the plan's placeholder codex/gcp-platform-baseline. The status table and this phase's branch line above record the branch actually used, per the session contract.

  • The Cloud Run service and both jobs ship with no Cloud SQL Auth Proxy sidecar, contrary to plan commit 8. The sidecar and the real image both arrive in Phase 3: a sidecar pointed at this environment's deliberately-stopped development Cloud SQL instance would never pass its startup probe, leaving a revision that never becomes Ready and failing the apply outright. The service exists — identity, scaling, and VPC egress wired — with the public placeholder image us-docker.pkg.dev/cloudrun/container/hello and no allUsers invoker binding, so it is not publicly callable yet.

  • Production's database tier moved from db-g1-small to db-f1-micro, and pre-launch production stays cold until MVP launch. Public list pricing puts db-g1-small near USD 26/month running, which alone exceeds ADR 0007's ideal USD 20 combined development-plus-production ceiling. The owner chose the smaller tier and decided pre-launch production stays cold until MVP launch. This contradicted ADR 0007's own accepted decision to "keep production Cloud SQL running," not only the Phase 1 module safeguard that enforced it. At the owner's direction, ADR 0007 was amended in place rather than superseded by a new ADR: its decision now reads that production Cloud SQL starts at MVP launch, and a "Production database activation" section records the reasoning, the amendment date, and the fact that amending an accepted ADR is a deliberate exception to the immutability convention in docs/CLAUDE.md. That section was itself corrected on this follow-up branch: production is created running, not stopped — Cloud SQL rejects activation_policy = "NEVER" at creation (see the real-apply findings above) — and is stopped immediately afterward as an operational step, by hand with gcloud since infra/scripts/db-power.sh refuses prod unconditionally. The Phase 1 module safeguard is replaced accordingly, not silently dropped: database_deletion_protection stays un-relaxable in prod and plaintext origins stay forbidden; there is no longer a database_start_stopped input to relax, since it never could have delivered a created-stopped instance in the first place.

    A consequence that belongs in the launch checklist rather than in Terraform: a stopped Cloud SQL instance takes no automated backups. Production has no backup coverage from the moment it is stopped after creation until launch, and backups start from the moment the instance is started, not retroactively. Starting production and confirming its first backup completes are explicit steps of the Phase 7 promotion, not side effects of it.

  • A stopped Cloud SQL instance takes no automated backups. Pre-launch production therefore has no backup coverage from the point it is stopped until it is started at launch — backups begin from that moment, not retroactively. This belongs in the launch runbook and is recorded in infra/modules/fpc-environment/README.md's "Development database start and stop" section so whoever starts production at launch sees it.

  • Cloud SQL runs PostgreSQL 18, Cloud SQL's default engine version since September 2025. CI's Postgres service container and apps/api/docker-compose.yml both moved to postgres:18-alpine so the application is tested against the version it runs on. The open question is recorded rather than hidden: this repository pins Prisma 6.10, which shipped before PostgreSQL 18 existed and was never validated against it upstream. The API suites now running in CI against Postgres 18 are what settle whether that combination holds; PostgreSQL 17 is the documented fallback if it does not.

  • The apply identity now holds roles/resourcemanager.projectIamAdmin, fenced by an IAM condition allowlisting exactly roles/cloudsql.client, roles/cloudsql.instanceUser, roles/logging.logWriter, and roles/monitoring.metricWriter. This was unavoidable: Cloud SQL has no resource-level IAM — the Google provider ships no google_sql_database_instance_iam_* resource — so those four roles, needed by the runtime, migration, and jobs workload identities, can only be bound at the project. Phase 1's approach of granting the narrowest binding at the resource instead was not available here. The fence is what makes the grant safe: none of the four allowlisted roles can grant further roles or impersonate another identity, and the condition denies any setIamPolicy call that touches a role outside the four.

  • The apply identity also gained roles/compute.securityAdmin, found by the real apply above. roles/compute.networkAdmin grants only compute.firewalls.get and .list; it cannot create the egress firewall rules network.tf declares. roles/compute.securityAdmin carries compute.firewalls.create, and a comment on the grant now records why networkAdmin alone is insufficient so the role is not narrowed back down without someone re-reading that reasoning.

  • The plan identity's roles/viewer was replaced with per-service viewer roles, fulfilling the promise Phase 1's checkov:skip=CKV_GCP_117 suppression made now that real resources exist to verify a narrower list against. The honest risk: sufficiency cannot be proven by mock_provider tests, only by a real project. That risk is contained, not unmanaged — the pull-request plan job runs the plan identity against the live project on every PR touching infra/, so a missing role fails that PR rather than silently reaching dev, and the fix is additive and cheap.

  • A Terraform testing limitation worth recording for whoever writes tests next: the test DSL cannot assert the absence of a resource type, because referencing an undeclared resource (for example, google_secret_manager_secret_version) is a static configuration error, not a false assertion that a test could catch at runtime. The "no secret payload in state" invariant is therefore enforced by a CI grep step in the infrastructure job (.github/workflows/ci.yml), not by a module test.

  • The public-assets bucket carries no allUsers grant, found by the real apply above. ADR 0007 calls for immutable team logos to be publicly readable, but the organization's Domain Restricted Sharing policy rejects that grant unconditionally. The owner deferred it to Phase 4, where logos are actually served, to choose there between an org-policy exception and signed URLs; public_access_prevention stays "inherited" on the bucket so that decision does not require recreating it.

  • Cost. Cloud SQL cannot be created stopped (see the real-apply findings above), so both roots' cost_forecast output now estimates the running cost of what Terraform actually creates: PD_SSD at the 10 GB default both roots configure is about USD 1.70/month, and a db-f1-micro instance adds about USD 8/month running, for about USD 9.70/month combined. This is a documented estimate, not a billing-API reading, and it cannot reflect the lower real cost once development or pre-launch production is stopped operationally, because Terraform no longer tracks activation policy as desired state after creation. Cloud Run, Artifact Registry (under 0.5 GB), Secret Manager containers, the VPC, Private Services Access, and Direct VPC egress carry no fixed monthly charge at MVP scale, and there is no Cloud NAT to add one. Production is not applied in this phase and carries no cost yet.

Before this phase can be applied, fpc-bootstrap must be re-applied for each environment. The apply identity's role list gained roles/servicenetworking.networksAdmin, roles/compute.securityAdmin, and the fenced roles/resourcemanager.projectIamAdmin, and the plan identity's role list changed from roles/viewer to per-service viewer roles. Bootstrap roots run locally under a human's Application Default Credentials, never through CI, so this is a manual step — see infra/README.md's "Re-running bootstrap after a role change" section for the exact sequence, which requires gcloud auth application-default login again and revoking that credential immediately afterward.

Least-privilege PostgreSQL roles (plan commit 4) are documented as a manual procedure, not implemented in Terraform, because the database has no public IP and no provider running in CI can reach it — see infra/modules/fpc-environment/README.md's "Least-privilege database roles" section.

Phase 3 - API Runtime and Private PostgreSQL

Branch: codex/gcp-api-runtime

Objective

Make the existing NestJS API runnable on Cloud Run against private Cloud SQL while Supabase authentication and storage remain temporarily available. This moves compute and persistence before changing user-facing authentication.

Commit Sequence

  1. Align the container runtime with the repository's Node.js version and preserve monorepo-root builds.
  2. Separate liveness, readiness, and warm-up interfaces. Liveness checks the process; readiness and warm-up perform a bounded database check.
  3. Make startup, shutdown, database pooling, signals, logging, and request timeouts safe for Cloud Run concurrency and termination.
  4. Add provider-neutral runtime configuration for the standard local PostgreSQL endpoint exposed by the proxy sidecar.
  5. Add the migration-job command that applies Prisma migrations without starting the HTTP process.
  6. Add container tests for API startup, liveness, readiness failure, graceful shutdown, and non-root execution.
  7. Add the image build, Artifact Registry push, migration invocation, and Cloud Run deployment workflow using GitHub federation.
  8. Deploy the development API without moving Vercel traffic and verify it against disposable development data.
  9. Add cold-start, request, database-pool, and startup-failure observability with bounded log retention.

Tests and Evidence

  • Existing unit, integration, end-to-end, and container tests pass against PostgreSQL.
  • The development Cloud Run revision reaches the database only through the proxy and private IP path.
  • A failed migration prevents the new revision from receiving traffic.
  • The stable run.app URL passes smoke tests while Railway remains unchanged.
  • Repeated warm-up calls are cheap, rate-limited, and reveal no infrastructure details.
  • Measured startup and first useful request latency are recorded for later minimum-instance decisions.

Rollback

Shift development traffic to the preceding Cloud Run revision or leave callers on Railway. Prisma migrations must be backward compatible during this phase; destructive cleanup waits until legacy retirement.

Phase 3 evidence

Phase 3 is complete and was merged to dev through PR #26. The development deployment was performed manually; the defined GitHub Actions deployment workflow has not supplied this acceptance evidence. Vercel, Railway, and production remain untouched.

Development platform and compatibility boundary

  • Development Cloud SQL is RUNNABLE with ALWAYS activation policy and was intentionally left running after acceptance. Development Supabase PostgreSQL data was disposable and was not migrated. Supabase Auth and Storage remain temporary compatibility dependencies.
  • The two development Supabase Secret Manager secrets now have enabled versions, and the non-secret repository variable GCP_DEV_SUPABASE_URL is configured. No payload value is stored in Terraform, Git, this plan, or its evidence.
  • Bootstrap IAM was applied. A post-gate inspection found no temporary bootstrap secret payloads, bootstrap objects, or Cloud SQL service-agent storage-bucket grant.

Migration and runtime acceptance

  • The API and migration Job run as separate dedicated identities and reach private Cloud SQL only through their Cloud SQL Auth Proxy sidecars using private IP and IAM database authentication. The API uses the runtime identity; the migration Job uses the migration identity.
  • The first migration execution, fpc-dev-migrate-546kg, failed with PostgreSQL permission denied for schema public; stable traffic remained on revision fpc-dev-api-00001. After the documented least-privilege grants, fpc-dev-migrate-p8bz8 applied all eight Prisma migrations.
  • Candidate revision fpc-dev-api-00004-jik deployed with zero traffic. Anonymous smoke checks returned 200 for /health/live and /health/ready; the first three /health/warmup calls returned 200, and the fourth returned 429. The exact smoked revision was then promoted to 100% traffic. The stable public run.app liveness and readiness checks both returned 200.
  • Runtime logs show the proxy and startup probes passing. Observed API bootstrap time was 190 ms, platform readiness was about 11.7 s, and the first candidate readiness check was 448 ms. The Prisma pool is limited to 5 connections with a 10 s pool timeout and 5 s connect timeout.
  • Live distribution log metrics have explicit histogram buckets, and the _Default Cloud Logging bucket retains logs for 30 days.

Infrastructure and validation evidence

  • The final live development Terraform plan was empty after commit 05051bc; the generated Cloud Run template revision is ignored while Terraform retains ownership of the sidecars, probes, environment, ports, and all container settings except the immutable API image.
  • Local validation passed: 155 API unit tests in 22 suites; 7 API end-to-end tests in 3 suites; 76 fpc-environment Terraform tests; 15 fpc-bootstrap Terraform tests; 22 workflow-contract/change-detector Node tests; API lint and build; Shared build; Prettier; and production/migration image builds and checks.
  • These are local test and manual live-operation results. They do not claim a GitHub Actions deployment execution or a production cutover.

Phase 4 - Object Storage Cutover

Branch: codex/gcp-storage

Objective

Replace Supabase Storage with a GCS-only deep object-storage module while keeping the app-facing team-logo URL behavior stable.

Current status

Phase 4 is complete in development. The API uses its GCS-only object-storage module, and Terraform manages the development public-assets bucket, private-media bucket, and project policy required for public team-logo delivery. Production remains on its existing Supabase Storage path until a later phase explicitly cuts it over.

Commit Sequence

  1. Define one dedicated API object-storage module whose sole runtime implementation is GCS, using workload identity and opaque object keys.
  2. Inject an in-memory fake only in tests to establish upload, delete, public-read, validation, cleanup, and failure behavior at the module seam.
  3. Persist only Team.logoObjectKey; drop the legacy logo URL column and media-visibility compatibility fields because both environments contain disposable data. Derive the app-facing logoUrl dynamically from the opaque key.
  4. Route team logos to immutable public GCS objects. Keep the private-media bucket available for future application-owned profile-media behavior, but add no private-media upload, signed-read route, or UI in this phase.
  5. Preserve server-mediated MVP uploads and existing size, MIME, extension, magic-byte, and image-dimension validation.
  6. Add cleanup for replaced and orphaned objects and ensure deletion is idempotent.
  7. Deploy the GCS-only module to development and observe it through the public-assets bucket before any production cutover.

Tests and Evidence

  • Module tests inject the fake at the GCS module seam; running environments use GCS only.
  • Logo responses are cacheable and do not require the API to proxy object bytes.
  • Existing team authorization tests still govern uploads and deletion.
  • No persisted team-logo record requires a Supabase or GCS hostname; it contains only an opaque object key.
  • No private-media route or UI is introduced. The future private-media behavior remains governed by ADR 0011 and the separate bucket controls.

Phase 4 evidence

PR #48 implemented the GCS-only API and storage changes. PR #50, merged as 90121c6ffc04ec14dec103ed36777e8d8a630e96, corrected the infrastructure blockers and completed the development rollout.

The final local validation covered the GCS-only implementation:

  • The focused API storage and integration set passed 76 tests.
  • API unit tests passed 24 suites and 168 tests; API end-to-end tests passed 3 suites and 7 tests against a disposable PostgreSQL 18 database.
  • Root tests passed 540 tests: Shared 50, API 168, and App 322.
  • Root lint, build, and Prettier checks passed; the production API Docker image built successfully.
  • The production-image Trivy scan found 0 HIGH/CRITICAL vulnerabilities and 0 secrets.
  • Terraform bootstrap passed 17 tests and the environment module passed 76 tests. Terraform fmt, init, validate, TFLint, and Checkov reported 216 passes and 0 failures. Trivy infrastructure-as-code scanning reported only 8 documented pre-existing low findings.
  • Independent Spec review found no findings. Standards review found no actionable documented-standard violations; it noted only judgement-call projection duplication and shotgun-surgery observations.

The development rollout established and verified the live storage boundary:

  • Trusted bootstrap established projects/372530350143/policies/iam.allowedPolicyMemberDomains with allowAll: true.
  • Terraform applied GCS_PUBLIC_ASSETS_BUCKET to Cloud Run and the unconditional allUsers roles/storage.legacyObjectReader binding on the public-assets bucket.
  • GitHub Actions run 31523281483 was retried after the infrastructure fix and completed image build, migrations, candidate smoke checks, and promotion.
  • Cloud Run revision fpc-dev-api-00020-say runs image tag 244f541114f0cbc901f485a8c67e192372dfe772 at 100% traffic.
  • An anonymous request for a known public probe object returned 200 with Cache-Control: public,max-age=31536000,immutable; an anonymous JSON listing of the public bucket returned 401; and an anonymous request for a known private probe object returned 403.
  • Disposable probe objects were deleted after the checks.

No profile-photo route or UI exists yet, so application-authorized private reads and five-minute signed URLs remain deferred with that profile behavior. Production remains unchanged; its cutover and private profile-media application behavior belong to later phases.

Rollback

Both environments are disposable. To recover from a failed development rollout, reset the disposable environment and database as necessary, redeploy a known-good GCS-only revision, and recreate the affected test media. Do not reintroduce Supabase Storage, provider selection, schema compatibility, or legacy URL reads as a rollback mechanism.

Phase 5 - Application Identity and WorkOS API Authentication

Branch: codex/workos-api-auth

Objective

Introduce application-owned users, WorkOS authentication, and authoritative membership roles in the API, and cut the web client over to WorkOS in the same phase so development runs entirely on WorkOS.

Scope Decisions

These decisions were taken at the start of the phase and change the commit sequence below from its original form:

  • No Supabase verifier is built. ADR 0008 was amended in place to withdraw its dual-verifier requirement; see its "Compatibility window" section. Development finishes this phase on WorkOS only.
  • The development database is reset, not backfilled. Both environments hold disposable data, so application-owned UUIDs arrive with a clean schema rather than a relationship-rewriting backfill.
  • Google is the only identity provider. Apple is deferred until before an App Store submission, because Android launches first and Google Play imposes no equivalent of App Store Review Guideline 4.8.
  • The web client cuts over in this phase; Capacitor does not. System-browser return handling, verified deep links, secure mobile token storage, and the production promotion remain in Phase 7. The client structure follows ADR 0014: provider adapters under the app's authentication module, platform adapters behind the platform capability seam.
  • Identity linking is delegated to WorkOS. FPC stores one identity mapping per WorkOS subject and never links accounts by email address, which also removes the existing email-conflict account merge.
  • WorkOS access tokens carry no audience claim. The WorkOS SDK verifies signature and expiry against a client-scoped JWKS endpoint, so the client-scoped JWKS plus an explicit issuer check is this implementation's audience equivalent.

Commit Sequence

  1. Add shared authenticated-user, session-bootstrap, membership-role, and authentication-error contracts.
  2. Add application-owned UUIDs and the issuer-plus-subject identity mapping with uniqueness, transaction, and relationship migrations, against a reset development database.
  3. Define the token-verifier interface and add a fake adapter that exercises success, expiry, issuer, and malformed-token behavior.
  4. Add the WorkOS verification adapter as the sole production verifier and remove Supabase token validation, its configuration, and its secret containers.
  5. Add idempotent login-time user synchronization keyed on the WorkOS subject, and remove email-based account merging.
  6. Add the signed WorkOS event endpoint, durable inbox, deduplication, retries, and out-of-order handling.
  7. Return current per-team membership roles from session bootstrap and enforce those roles in protected mutations.
  8. Add the app authentication module with its WorkOS adapter and the platform capability seam, and cut the web login and API client over from Supabase.
  9. Remove the app's Supabase client, hooks, environment variables, and dependency.
  10. Configure the WorkOS development application, the Google provider, redirect allowlists, and secret values outside Terraform state.
  11. Deploy to development and record authentication outcome metrics without logging tokens or personal data.

Tests and Evidence

  • Existing authorization tests pass with application-owned user IDs.
  • Unknown issuers, expired tokens, malformed claims, and deleted identities fail closed.
  • Concurrent first logins create one FPC user and one identity mapping.
  • Replayed webhooks create one inbox result; out-of-order updates cannot resurrect deleted access.
  • A user can hold different roles in different teams, the UI contract reports them, and the API remains authoritative.
  • WorkOS Organizations and RBAC are absent from the implementation.
  • A real Google login through the development web client reaches the Cloud Run API and returns an authenticated session.
  • No Supabase authentication code, configuration, secret container, or dependency remains in the API or the app.

Rollback

Redeploy the previous Cloud Run revision together with the previous web deployment, since no verifier accepts a legacy token once this phase ships. This is acceptable only because both environments hold disposable data. Do not reverse application-owned user IDs after relationship migration.

Phase 5 evidence

Implementation is complete and validated locally. The live development rollout has not happened: the WorkOS application does not exist yet, no secret payload is loaded, and no Cloud Run revision runs this code. The operator executes WorkOS development setup first, and the live evidence is appended here afterward. Nothing below claims a deployment.

Local validation:

  • Shared: 5 files, 66 tests.
  • API: 29 suites, 204 unit tests; 4 suites, 15 end-to-end tests against real PostgreSQL. Build reports 0 TypeScript issues.
  • App: 46 files, 360 tests. Playwright: 2 passed, 0 skipped.
  • Terraform: 77 fpc-environment tests, 18 fpc-bootstrap tests, fmt and validate clean across both roots and both modules, TFLint clean.
  • Change-detector and workflow-contract tests: 31.
  • No SUPABASE occurrence remains in apps/api/src, apps/api/test, apps/app/src, or either .env.example.

Design decisions worth recording, because a reader would otherwise have to reconstruct them:

  • Identity resolves inside the guard. JwtAuthGuard resolves (issuer, subject) through IdentityResolutionService before any controller runs, so AuthenticatedUser.id is the application-owned UUID everywhere. Roughly forty @CurrentUser() endpoints across users, teams, matches, home, and invitations were therefore left untouched. The cost is one indexed lookup per request.
  • The tenant check is the JWKS URL, not the issuer. WorkOS access tokens carry no aud claim, and iss is https://api.workos.com/ for every WorkOS customer. Only the client-scoped JWKS at https://api.workos.com/sso/jwks/<client_id> binds a token to FPC; a token minted for a different WorkOS customer shares the identical issuer. The issuer check is defence in depth. workos-token-verifier.ts carries this reasoning inline so the client id is not later refactored out of the URL.
  • Provisioning is concurrency-safe by constraint, not by check. First login creates the user and identity in one transaction; a (issuer, subject) unique violation re-reads the winner instead of retrying the create, so simultaneous first logins yield exactly one user.
  • Revocation is terminal by construction. user.created and user.updated never write to AuthIdentity at all, so no code path can clear revokedAt. Out-of-order delivery cannot resurrect access without relying on timestamp comparison.
  • Email collisions fail closed. A second WorkOS subject presenting an existing email returns 409 auth_identity_email_conflict and is logged without the address. The previous behavior silently returned a different user's id than the token subject.
  • Playwright uses a build-time composition, not a runtime bypass. The fake auth client is selected at the composition root under a dedicated Vite mode. A production build contains no reference to it, verified by grep across dist/ with a positive control confirming the e2e build does include it.

Two risks no test can settle, because every suite verifies against fake adapters by design. Both are recorded in the runbook's verification section with ordered remedies:

  • devMode: false keeps the refresh token out of localStorage as ADR 0008 requires, but leaves it in memory only. Whether a session survives a page reload depends on WorkOS's cookie and a cross-origin refresh that some browsers block.
  • The verifier pins algorithms: ['RS256']. A signing-algorithm mismatch fails closed and surfaces as auth_token_invalid_signature in the api_authentication_outcome metric.

Cost: WorkOS AuthKit is free to 1,000,000 monthly active users, then USD 2,500 per additional million, on pay-as-you-go with no fixed platform fee. FPC's forecast contribution is USD 0.

WorkOS development environment configuration completed so far, verified by reading the settings back rather than trusting the write:

  • Google is the only enabled sign-in method. Apple, GitHub, Microsoft, email-and-password, magic auth, passkeys, SSO, and IdP-initiated SSO are all disabled. This mattered more than expected: a new WorkOS environment enables Apple, GitHub, Microsoft, password authentication, and SSO by default, so the original runbook instruction to "enable Google only" would have left five unsupported sign-in paths open. The API provisions an FPC user for any validly signed WorkOS token regardless of which method produced it, so those paths were reachable. The runbook now states the correction.
  • http://localhost:5173/auth/callback is the sole allowlisted redirect URI, and the default.
  • The environment holds no users.

Outstanding before the live rollout: the Google OAuth client credentials, both secret payloads in Secret Manager, the GCP_DEV_WORKOS_CLIENT_ID repository variable, the deployed development web origin as a second redirect URI, and the webhook endpoint, which cannot be registered until the API is deployed and its Cloud Run URL exists.

WorkOS exposes no environment-deletion operation through either its connector API or its CLI — workos env remove edits only local CLI configuration — so an unused sandbox environment cannot be removed programmatically.

A trap for the production promotion: workos_client_id is nullable and defaults to null in the production root, so WORKOS_CLIENT_ID is simply omitted from Cloud Run when unset, while the API requires it at startup. A production apply that forgets it produces a revision that never becomes ready. It fails closed and loudly rather than silently, but it will cost time during promotion if unanticipated.

Phase 6 - Scale-to-Zero Background Work

Branch: codex/gcp-background-jobs

Objective

Remove the in-process scheduling dependency that would silently stop when Cloud Run scales to zero.

Commit Sequence

  1. Extract reminder selection and delivery into a deep dispatcher module whose interface accepts an execution time and returns an auditable result.
  2. Add database-backed idempotency so retries cannot create duplicate reminder records or notifications.
  3. Add focused tests for due selection, locale behavior, partial failure, retry, and concurrency.
  4. Add a job entrypoint that uses the same application implementation without starting the HTTP listener.
  5. Configure the Cloud Run Job and Scheduler invocation through the existing Terraform module.
  6. Run the job manually in development, then enable the development schedule.
  7. Remove the NestJS in-process cron registration only after scheduled executions and retries are observed successfully.
  8. Add job duration, success, failure, retry, and duplicate-suppression observability.

Tests and Evidence

  • Repeated and overlapping invocations produce one logical reminder outcome.
  • A failed recipient does not corrupt successful recipient outcomes.
  • The HTTP service can remain at zero instances while scheduled reminders still execute.
  • The scheduler identity can invoke only the intended job.
  • Job runtime remains within the expected free allowance and timeout.

Rollback

Disable the Scheduler target and invoke the job manually while correcting it. Do not restore the in-process cron after production scale-to-zero is enabled because its execution guarantee is incompatible with zero instances.

Phase 7 - WorkOS App Cutover and Production Promotion

Branch: codex/workos-app-cutover

Objective

Finish the functional migration in the user-facing web and Capacitor app, validate the complete development flow, and promote the approved stack to production.

Commit Sequence

Phase 5 delivered the app authentication module, its fake adapter, the platform capability seam, WorkOS authorization-code behavior for the Google control on the web, and the API client's token source. This phase covers what remains: the native platform and the production promotion.

  1. Implement the native platform capability adapter, which currently throws. This is the Capacitor half of the seam ADR 0014 defines: system-browser authorization through Chrome Custom Tabs, verified App Link return, and Keystore-backed secure token storage.
  2. Add Android deep-link verification, including the assetlinks association served from the web origin.
  3. Complete session handling across both platforms: refresh, logout, cancellation, and deep-link failure recovery.
  4. Resolve the web session-restoration question recorded in the Phase 5 evidence, using the measured behavior of the deployed development client rather than a predicted outcome.
  5. Add Sign in with Apple if an App Store submission is in scope for the release; Google Play does not require it.
  6. Point development builds at the stable Cloud Run run.app URL and configure exact web, preview, and Capacitor CORS origins. The Capacitor WebView origin must be added to allowed_origins in the environment module.
  7. Send the non-blocking warm-up request as soon as the app opens, concurrently with authentication and session restoration.
  8. Populate current per-team roles from session bootstrap through TanStack Query and invalidate them after role-changing operations and normal refocus.
  9. Preserve the custom FPC welcome experience, onboarding routing, and linked Google/Apple behavior.
  10. Add web and Capacitor tests for successful login, cancellation, deep-link failure, expired state, refresh failure, logout, cold API startup, and role-dependent affordances.
  11. Deploy to the development Cloudflare Pages environment and complete an end-to-end soak against Cloud Run, Cloud SQL, GCS, Jobs, and WorkOS.
  12. Decide from measured startup data whether production remains at zero minimum instances or temporarily uses one.
  13. Promote dev to main, apply production infrastructure with approval, seed required reference data, deploy the API, configure WorkOS production redirects, and update Cloudflare Pages production environment values. Set workos_client_id in the production root before applying: it is nullable and defaults to null, so an unset value omits WORKOS_CLIENT_ID from Cloud Run entirely and the API refuses to start.
  14. Monitor authentication success, API errors, cold starts, database connections, job runs, storage errors, and cost through the rollback window.

Tests and Evidence

  • Google and Apple login work on the production web origin and supported Capacitor platforms.
  • Linking supported identities resolves to one FPC user.
  • Session restoration, refresh, logout, cancellation, and deep-link error recovery satisfy the existing PRD behavior.
  • Team roles appear correctly and every protected mutation still fails server-side when unauthorized.
  • The app issues warm-up immediately and measured first-use latency meets the ten-second target or documents the minimum-instance mitigation.
  • Cloudflare Pages serves app.feelproclub.com; the app calls the production run.app endpoint directly.
  • Full repository tests, Playwright flows, mobile deep-link validation, container checks, infrastructure plans, and security review pass.
  • Production cost alerts and the first post-cutover estimate remain within the approved envelope.

Rollback

Restore the previous Cloudflare Pages deployment and API base URL, keep Railway and Supabase authentication available, and shift users back without reversing application-owned IDs or provider-neutral object keys. Production database migrations must remain backward compatible until the rollback window ends.

Phase 8 - Legacy Retirement and Final Documentation

Branch: codex/retire-supabase-and-railway

Entry Gate

Begin only after the Phase 7 production soak completes with no unresolved authentication, storage, database, reminder, latency, or cost regression and after the rollback window is explicitly closed.

Objective

Delete temporary compatibility seams and stop paying for or maintaining the legacy backend path.

Commit Sequence

  1. Remove Railway deployment configuration and legacy deployment documentation.
  2. Remove migration-only feature flags.
  3. Make WorkOS, GCS object storage, private Cloud SQL, and Cloud Run configuration mandatory in the appropriate environments.
  4. Update API, App, Shared, root, and living architecture documentation to describe the shipped implementation.
  5. Update changelogs and mark the migration plan complete with production evidence.
  6. Run a repository-wide search proving no runtime Supabase or Railway dependency remains; retain only ADR and migration-history references.
  7. Decommission the Railway service and Supabase project only after configuration, object, and database backups needed for the agreed retention window exist.

Tests and Evidence

  • Fresh installs, all workspace tests, E2E, container validation, VitePress build, Terraform validation, and production smoke tests pass without Supabase or Railway secrets.
  • Dependency manifests and lockfiles contain no runtime Supabase client unless another approved feature requires it.
  • The current architecture overview matches the deployed platform.
  • Railway and Supabase are no longer receiving production traffic.
  • Final cost, cold-start, authentication, storage, and scheduled-job evidence is attached to this plan.

Rollback

This phase begins only after the application rollback window closes. Recovery uses retained backups and version history, not re-enabling unmaintained compatibility code. Decommissioning external projects is the final, separately confirmed destructive operation.

Production Milestones

MilestonePromotionPurpose
Decisions mergedPhase 0 to devMakes ADRs and execution plan authoritative for implementation sessions
Development platform readyPhases 1-3 in devVerifies private infrastructure and API runtime without production traffic
Development replacement completePhases 4-7 in devVerifies storage, WorkOS, jobs, and app end to end
Production cutoverReviewed dev to main promotion in Phase 7Creates or updates production resources and switches Cloudflare Pages/API traffic
Legacy retirementPhase 8 through dev, then mainRemoves compatibility code and decommissions external services after soak

Testing Decisions

  • Test observable behavior through module interfaces; avoid tests that lock in adapter internals.
  • Use fake adapters for WorkOS and object storage unit tests, real PostgreSQL for API integration tests, and real development GCP resources only for explicit infrastructure and smoke-test gates.
  • Preserve and update existing JWT, storage, user, team authorization, session-bootstrap, welcome-screen, Playwright, container, and change-detector coverage.
  • Add contract tests that every production adapter and fake adapter must pass.
  • Keep Terraform validation tests non-provisioning by default. Any test that creates cloud resources must run in development, declare its cost, and prove cleanup.
  • Treat authorization, webhook validation, signed URLs, IAM, secrets, CORS, deep links, and deployment federation as security test surfaces.
  • Record evidence in the phase row rather than claiming completion from a passing unit suite alone.

Out of Scope

The original "moving the frontend away from Vercel" exclusion no longer holds and has been removed: ADR 0015 moved the web application to Cloudflare Pages because Vercel's Hobby plan does not permit org-owned private repositories or commercial use, and Vercel Pro alone would have exceeded this plan's cost baseline. See the "Frontend Hosting Amendment" subsection above.

  • A custom api.feelproclub.com hostname, Application Load Balancer, Cloud Armor, CDN, or Firebase edge.

    api.feelproclub.com stays deferred; ADR 0007 explicitly accepted a run.app hostname as a known downside. Recorded here so a future reader does not re-derive it: three mechanisms exist, and the cheapest changed once DNS moved to Cloudflare in ADR 0015. A Cloud Run domain mapping is free but has limited regional availability and requires Google domain verification. A global external Application Load Balancer with a serverless NEG is robust but its forwarding rule alone costs roughly USD 18/month, which is why this plan excluded it above. A Cloudflare proxy was investigated and rejected: Cloud Run routes by hostname, so a proxied CNAME api → <service>.run.app returns 404 without a Host header rewrite, and Origin Rules' Host header override is Enterprise-only — verified against a real free-plan zone, where the certificate resolved correctly on a first-level hostname and the request still reached Cloud Run as a 404. A Cloudflare Worker could rewrite the host instead, but that puts a runtime component in the API request path with a free-tier request ceiling, in exchange for a cosmetic hostname. Adopting a custom hostname later is cheap regardless of which mechanism is chosen, because the client reads VITE_API_URL from configuration: the change is one environment variable plus one CORS origin, which is why deferring it costs nothing now.

  • HA Cloud SQL, read replicas, multi-region failover, or an SLA beyond MVP needs.

  • ECS, EKS, GKE, GCE, or another always-on cluster.

  • WorkOS Organizations, RBAC, enterprise SSO connections, Directory Sync, or MFA policy beyond the selected Google and Apple MVP flow.

  • Realtime role-change delivery to already-open clients.

  • Push, email, cleanup, or image-processing jobs other than extracting the existing reminder schedule.

  • Migration of real production users, database rows, or objects; the MVP currently contains disposable data.

  • A cloud-neutral Terraform module or simultaneous AWS/Azure implementation.

  • Direct-to-object-storage uploads; the backend remains the upload validation point for the MVP.

  • A permanent production minimum instance unless latency measurements justify it.

Definition of Done

The migration is complete only when:

  • Cloudflare Pages serves the app at app.feelproclub.com and the app calls Cloud Run directly.
  • Cloud Run can scale to zero and scheduled reminders continue through Cloud Run Jobs.
  • Cloud SQL is private, IAM-authenticated, backed up, and reachable only through approved workload identities.
  • WorkOS authenticates Google and Apple users while FPC owns user IDs and team roles.
  • GCS serves public logos and authorized private media through provider-neutral object keys.
  • GitHub deploys development and production through short-lived GCP credentials and protected approvals.
  • Terraform reproduces both environments from separate state without Terragrunt.
  • Supabase and Railway runtime dependencies are removed and their external services are decommissioned after the rollback window.
  • Living architecture, operational documentation, environment examples, and changelogs match production.
  • Measured recurring cost and cold-start behavior satisfy the approved MVP targets or document an explicitly accepted exception.