Skip to content

Production landscape

Production decisions are locked; nothing is provisioned yet. This page mirrors docs/PRODUCTION.md, which is the authoritative version and carries the full cost model, the residency analysis, and ADR-001 through ADR-015.

Development stays where it is — a single Hostinger VPS under Dokploy. See Dev environment (Dokploy) and Deployment.

flowchart TB
    subgraph Edge["Google edge (global)"]
        DNS["Cloud DNS"]
        LB["Global External HTTPS LB"]
        ARMOR["Cloud Armor — WAF, rate limit, geo"]
        CDN["Cloud CDN"]
    end

    subgraph USC["us-central1 (Iowa)"]
        subgraph CR["Cloud Run"]
            WEB["svelte-web (service)"]
            API["api (service)"]
            IMG["imgproxy (service)"]
            DOCS["docs (service) — DEV/STG only; not in PRD"]
            WORKER["worker (worker pool)"]
        end
        subgraph VPC["VPC — private IP only"]
            SQL[("Cloud SQL PostgreSQL + PostGIS")]
            MEM[("Memorystore for Valkey — CMD")]
            MEILI[("Meilisearch — GCE VM + PD")]
        end
        GCS[("Cloud Storage — rec-uploads-prd (NAM4 dual-region: us-central1 + us-east1)")]
    end

    Browser["Browser"] --> DNS
    Mobile["Native mobile"] --> DNS
    DNS --> LB
    LB --> ARMOR
    ARMOR --> CDN
    CDN -->|serverless NEG| WEB
    CDN -->|serverless NEG| API
    CDN -->|serverless NEG| IMG
    LB -.-|not deployed to PRD — ADR-015| DOCS
    WEB -->|Direct VPC egress| SQL
    WEB --> MEM
    WEB --> MEILI
    API --> SQL
    API --> MEM
    WORKER --> SQL
    WORKER --> MEM
    WORKER --> MEILI
    WEB --> GCS
    IMG --> GCS
    WORKER --> GCS

All three stateful services hold a private IP only — the same property the dev environment has on dokploy-network. Cloud Run reaches them over Direct VPC egress (no connector VM).

AreaProduction choice
RegionGCP us-central1 (Iowa) — both tiers; moved from Querétaro 2026-08-08 for cost, services, and DR primitives
ComputeCloud Run services (web, api, imgproxy — docs is not deployed to PRD, see prerequisite 7) + Cloud Run worker pool (BullMQ consumer). Ingress locked to internal-and-cloud-load-balancing.
PostgresCloud SQL for PostgreSQL, HA + PITR — PostGIS 3.5.2 available
ValkeyMemorystore for Valkey, Cluster Mode Disabled (mandatory)
MeilisearchSelf-hosted GCE VM + PD, private IP — the one deliberate exception to managed-where-possible
Object storageCloud Storage us-central1; rec-uploads-prd dual-region (+ us-east1), Workload Identity — Cloudflare R2 retired
EdgeCloud DNS → Global External HTTPS LB → Cloud Armor → Cloud CDN
SecretsSecret Manager — automatic (multi-region) replication
EnvironmentsDEV (Dokploy) · STG (GCP) · PRD (GCP)
DEVSTGPRD
PlatformHostinger VPS + DokployGCP us-central1GCP us-central1
QUEUE_SUFFIX-dev-stg(empty)
PostgrescontainerCloud SQL, no HACloud SQL, HA + PITR
ValkeycontainerMemorystore CMDMemorystore CMD + replica
Object storageGCS rec-uploads-devGCS rec-uploads-stgGCS rec-uploads-prd (dual-region)
GCS authservice-account JSON keyWorkload IdentityWorkload Identity
Datadisposabledisposablereal, backed up

The DEV column is target state — DEV stores objects in Cloudflare R2 today, and its bucket migration to rec-uploads-dev is sequenced before STG and PRD are provisioned (DEV itself stays on the VPS). QUEUE_SUFFIX must differ across all three or producers and workers steal each other’s jobs. Because DEV runs outside GCP it cannot use an attached service account, so it needs one GCS service-account key scoped to rec-uploads-dev — the single accepted long-lived credential in the landscape.

A bucket needs a project and a project needs an organization, and as of 2026-08-16 there is no organization — everything in GCP sits in three projects owned by a personal account (Maps + the OAuth client, Gemini, Firebase/FCM). That is the status quo ADR-014 replaces, and its sharpest problem is ownership: projects created outside an organization belong to the individual who created them, not to the company.

The decision is one organization, a folder per environment, a project per environment, plus shared projectsrec-bootstrap (Terraform state, the GitHub Workload Identity pool), rec-shared (Artifact Registry, Cloud DNS, the ops bucket), rec-identity (OAuth clients, Maps and Gemini keys — the existing project, migrated in), and rec-dev / rec-stg / rec-prd. It costs $0; the whole price is friction. Four consequences worth knowing before you touch a console:

  • Bucket and project names are globally unique and can never be renamed. This is why the buckets are rec-uploads-* and not uploads-*, and why the rename had to land before anything was created — the names get hardcoded into IMGPROXY_GCS_ALLOWED_BUCKETS and the gs:// rewrite.
  • Cloud Identity Free has no mailbox, so budget and security notifications bounce silently unless a routing rule or alias groups are set up at creation. The failure mode is that the alerting fails.
  • run.allowedIngress at the folder is the highest-value policy in the set — it turns the Cloud Run ingress lockdown (prerequisite 6 below) from something that fails silently into a deploy-time error. The matching trap: domain-restricted sharing blocks the allUsers binding that public Cloud Run services need, so the invoker IAM check is disabled on the three public services instead.
  • Migrating the existing OAuth project into the org is one-way, and its consent screen must stay External — Internal after migration authorizes only org members, which would break Google sign-in for every customer.

CI reaches STG and PRD by Workload Identity Federation, with PRD bound to a GitHub Environment claim rather than a branch, so no service-account key ever enters CI. Full structure, IAM groups, the day-one org-policy list, and the numbered bootstrap are in docs/PRODUCTION.md §9.1. The production domain is not chosen yet, and it is the first blocking step.

Both GCP tiers share us-central1 — PRD moved out of Querétaro on 2026-08-08 for cost, service coverage, and DR primitives. ADR-001 in docs/PRODUCTION.md records the trade plainly: Mexican users pay roughly 40–50 ms more round-trip on requests the CDN cannot answer, and the in-country data story is given up (lawful — see Data residency below). STG therefore rehearses region and platform alike, at PRD’s exact rate card. The rule that stays: STG holds synthetic data only — never seeded from production.

StoreRPORTONotes
Cloud SQL≤ 5 min≤ 30 minHA standby + PITR. Only store with unrebuildable data; scheduled restore drills are the control that matters.
Memorystore Valkey≤ 1 h (instance/zonal); unbounded on region loss≤ 15 minNot disposable, and no longer only a performance dependency — in-flight BullMQ jobs live in db1, and the auth-, otp-, billing-, cfdi- and admin- rate-limit scopes fail closed, so an outage stops sign-in rather than degrading it. AI queues are protected by the Postgres dispatch outbox; leads, alerts, and search-indexing are not.
PII_ENCRYPTION_KEYn/a — nothing backs it up but the escrow you makenone: loss is permanentThe only key whose loss destroys data rather than access. It encrypts six fiscal columns (RFC, razón social, CP, delivery email, and the CFDI receiver snapshot), and the ciphertext is what every Cloud SQL backup and us-east1 copy contains — restoring to the minute before the loss returns the same unreadable bytes. It destroys cfdi_documents rows kept past account deletion for legal reasons. Rotation is unimplemented and the current design does not allow one, so treat the key as immutable for the life of the data. Escrow it outside the deployment platform before PRD writes its first encrypted row, and rehearse the restore — see Settings → Secrets.
Meilisearchn/a≤ 2 hFully rebuildable from SEPOMEX + the search-indexing queue. Keep locations/locations.xml in GCS — it is not in git.
Cloud Storage~0 zonal; ≤ 12 h cross-region~0Object versioning + lifecycle rules; rec-uploads-prd dual-region (NAM4, default replication — turbo is the 15-minute-SLA upgrade) survives region loss natively.

Zonal failure is fully covered by the Cloud SQL HA standby and the Memorystore replica. For region loss the intent is partial coverage — protect the data, accept the outage: there is no standby compute, no cross-region replica, and no DNS failover, so a regional outage is an extended outage recovered by restoring into a new region. Data coverage is native: Cloud SQL backups store in us-east1, and the dual-region bucket replicates asynchronously (default replication, ≤ 12 h window — typically minutes).

Mexico’s LFPDPPP (in force 21 March 2025) imposes no data-localization mandate, and the USMCA bars requiring local computing facilities as a condition of doing business (Art. 19.12). Hosting region is a business decision, not a compliance one — and production now exercises it in favour of us-central1 (US); the original in-country posture was a latency/procurement/trust preference, consciously traded away.

What compliance requires is not simply disclosure — it turns on how each recipient is classified. A remisión to an encargado (a processor acting on our instructions) needs no data-subject notice or consent. A transferencia to a third party requires communicating the privacy notice and consented purposes plus an accept/reject clause, unless a statutory consent exception applies. Most vendors here are intended to be encargados, which is materially lighter — and misclassifying in either direction is the real risk. The law provides no GDPR-style SCCs, but instruments do exist (the intra-group “common policies” exception, recognised binding self-regulatory schemes); the architecture just differs from the EU model. Counsel classifies each provider and records the contract, notice, and consent-or-exception basis per flow — this page supplies the inventory, not the legal conclusion. Article-level citations are left to counsel (the 2025 Law renumbered the 2010 framework and its implementing regulation is pending), and US hosting is not settled compliance until that per-flow sign-off exists.

Customer and agent PII, listings, images, and both search indexes live in GCP us-central1 (US); Cloud SQL backups and the dual-region rec-uploads-prd bucket add copies in us-east1, while the caches and search indexes are rebuildable and have no cross-region replicas. No store we operate keeps data in Mexico — but one flow deliberately sends fiscal data there, and this page previously said no such flow existed. CFDI stamping is not an oversight to close: a CFDI has no value unless the SAT stamps it, so cfdi-scan sends the receiver’s RFC and razón social to FacturAPI, which routes them through a SAT-authorised PAC to the SAT itself. Both are Mexican-resident recipients. FacturAPI’s own terms say it hosts in the United States, but that is a vendor claim from a privacy policy dated 2018 with no subprocessor register or DPA behind it — ask them in writing for the current region, the current PAC, and a DPA. The other Mexico-touching surface is transient: Cloud CDN edge caches serve public assets (listing images, static files) from POPs that include Mexico; API and SSR responses are not CDN-cached. Counsel’s classification work therefore includes Google Cloud’s US hosting of the first-party stores (counsel to classify — Google’s Cloud Data Processing Addendum is the anticipated remisión basis), the ten cross-border recipients the privacy notice will need to reflect — Knock, GetStream, Resend, Sentry, PostHog, Replicate, Gemini, Google Maps Platform, Stripe and FacturAPI, the last two holding payment instruments and SAT fiscal identity, the most sensitive classes in the stack — and, separately, the domestic leg to the PAC and the SAT, which has a statutory basis rather than a commercial one. Note the Meilisearch index is not PII-free — it holds agent names, headlines, and cities — which is why it stays self-hosted inside the VPC rather than with another processor (Meilisearch Cloud is a post-cutover open question now that the region is the US).

At launch, infrastructure dominates (~$700/mo) and nearly every SaaS vendor sits inside its free tier. At growth, SaaS roughly equals infrastructure and three vendors drive most of the increase:

  • GetStream — the sharpest cliff in the stack: free to 500 chat MAU, then ~$399/mo with nothing in between.
  • Knock — free to 10k notifications/mo, then $250/mo for 50k plus $5 per extra 1k. Volume grows with listings, not just users, because saved-search-scan and sla-monitor are repeatable jobs.
  • Google Maps Platform — the universal $200/mo credit is gone; each SKU now has its own ~10k/mo allowance. Autocomplete sessions are free only when closed by a Place Details call.

Stripe is deliberately absent from both columns. Payment processing scales with revenue, not with the user/listing/request targets this model takes as inputs, so a number there would be invented — but it is the largest SaaS line the moment there is revenue (roughly 3.6% + MX$3.00 per domestic card charge, plus Stripe Billing’s volume fee). FacturAPI is the opposite: $299 MXN/mo plus $0.60 per timbre rounds to noise at either scale.

The rates were re-verified on 2026-08-16 against the Cloud Billing Catalog API. Most matched exactly — Memorystore at $0.1923/node-hour, Cloud Armor at $5/policy + $1/rule + $0.75/M requests, Cloud Run’s request-billed CPU and memory. Three did not: CDN cache egress to North America is $0.02/GiB, not the ~$0.08 modelled (a 4× overstatement, which makes the R2 → GCS egress cost smaller than the argument for it assumed); worker pools are ~53% cheaper than request-billed services rather than ~40%; and Adaptive Protection requires Cloud Armor Enterprise at roughly $400/month, so the automatic defence against Armor’s own per-request billing exposure is a purchase, not a default. At launch a rate-limit rule plus a request-rate alert is the proportionate posture.

Full two-column model with cited unit rates is in docs/PRODUCTION.md. The cited figures are us-central1 rates — the deployment region for both tiers — so the model needs no cross-region confirmation; the one estimated line is dual-region storage for rec-uploads-prd.

Discovered in the code, not optional. Full list in docs/PRODUCTION.md; the ones with teeth:

  1. Memorystore must be Cluster Mode Disabled. packages/server-services/src/queue/connection.ts pins db: 1 for BullMQ, and @repo/redis-search issues MULTI/pipeline across un-hash-tagged psearch:* keys. Cluster Mode Enabled supports neither multiple databases nor cross-slot multi-key operations — it breaks queues and indexing.
  2. TRUSTED_PROXY_CIDRS must be re-derived for Google’s load balancer — and the header the code reads has to change with it. This is the one prerequisite whose obvious execution makes things worse. getTrustedClientIp() prefers the single-valued cf-connecting-ip / x-real-ip, then falls back to the leftmost x-forwarded-for entry. Google’s load balancer sets neither single-valued header and appends to XFF, so the leftmost entry is whatever the caller sent. Setting the CIDRs and stopping there swaps today’s fail-closed default for an attacker-controlled value feeding the auth and OTP limiters — which, since Better Auth’s own IP-based limiter was disabled, are the only ones left. The fix that needs no code: add a backend-service custom request header X-Real-IP: {client_ip_address} — load-balancer headers overwrite a same-named client header, and the code already prefers x-real-ip. Verify with a forged header on STG, and measure the actual socket peer there rather than looking up published LB ranges.
  3. Schema drift blocks the first migration run — resolved (Aug 2026). The live dev database carried tiger (32 tables) and topology (2) from postgis_tiger_geocoder / postgis_topology, installed by hand outside the migration chain and baked into the generated types by db:codegen. The extensions were dropped from the dev database (nothing referenced them), the types regenerated, and db:codegen now pins --include-pattern "public.*" so hand-installed schemas can never re-enter the generated types.
  4. GCS V4 signed URLs under ADC need roles/iam.serviceAccountTokenCreator — the worker’s 20-minute presigned source URLs for AI staging depend on it.
  5. Cloud CDN TTL must not exceed the imgproxy signature TTL. exp: is inside the HMAC, so rotating IMGPROXY_KEY / IMGPROXY_SALT invalidates the entire cache.
  6. Cloud Run ingress must be internal-and-cloud-load-balancing on svelte-web, api, imgproxy, and docs. The default *.run.app URL bypasses the load balancer — and with it Cloud Armor’s WAF and rate limits, the TRUSTED_PROXY_CIDRS assumptions, and CDN caching. Verify both directions: direct *.run.app requests rejected, production domains still served. This fails silently — everything works, it is just unprotected.
  7. The apps/docs access boundary — decided (ADR-015): it is not deployed to PRD. It documents infrastructure topology, env-var names, and security controls, so it must not be anonymously readable in production; and since it serves developers who already have DEV/STG access, not serving it is cheaper than guarding it with IAP. PRD’s load balancer needs no docs backend at all. The negative test moves to STG, where the page does exist.
  8. Bull Board is not deployed in production. A worker pool has no URL, and PRD goes without rather than building an internal-LB-plus-IAP path for it — so BULL_BOARD_PORT / BULL_BOARD_USER / BULL_BOARD_PASSWORD become DEV- and STG-only. The scripts this obligated now exist: apps/worker/scripts/queue-admin.ts (bun run queue:admin) inspects all six queues, retries failed jobs on the five mutable queues (ai-staging is read-only, reconciler-managed), and replays the failed_lead_ingestions DLQ for leads, with the runbook and rehearsal procedure in Incident guides. The remaining PRD-side work is the one-off Cloud Run job invocation, which lands with provisioning.
  9. The fiscal-PII key must be escrowed and its restore rehearsed before PRD writes its first encrypted row. PII_ENCRYPTION_KEY cannot be re-issued and the backups hold only ciphertext, so this is the one prerequisite whose failure is permanent rather than recoverable. Gates: an offline copy outside the deployment platform, the vault and item name recorded in Settings → Secrets (that line reads ⚠️ NOT YET RECORDED today), a documented restore test against a restored Cloud SQL clone, and a distinct key per environment.
  10. Stripe and FacturAPI need live-mode accounts — dashboard work, no code, and a launch gate. Stripe: prices matching the catalog by lookup key and amount, the webhook subscribed to exactly the allowlisted events, the portal restricted to cancel-at-period-end with subscription_update off, dunning set to cancel. FacturAPI: the organization created and its CSD certificates uploaded, since a live key with no certificates stamps nothing. Do not install FacturAPI’s Stripe marketplace app — it double-stamps every paid invoice, and a duplicate CFDI can only be undone by a filed cancellation.

docs/PRODUCTION.md carries ADR-001 through ADR-015 with consequences: region, compute, Cloud SQL, Memorystore CMD, self-hosted Meilisearch, GCS + Workload Identity, the Google edge, US-hosting-as-lawful-choice, three environments, Secret Manager, recovery targets, then — post-cutover, explicitly not a cutover dependency — shrinking Meilisearch’s surface by moving its three empty-query paths and one primary-key fetch to Postgres, then payments — Stripe with CFDI stamping via FacturAPI, which as of Aug 2026 records a built system rather than a direction — the GCP account structure summarised above, and finally the decision not to deploy apps/docs to PRD at all.

Payments and subscriptions are no longer a gap. M1 gating and M2 self-serve shipped Aug 2026: entitlements and six server-enforced gates, Stripe-hosted Checkout for every audience (cards-only — OXXO/SPEI can’t do recurring billing), per-seat TEAM checkout, first-party plan change, a signature-verified webhook, a daily stripe-reconcile, CFDI 4.0 facturación through FacturAPI, and full-refund → SAT cancellation (packages/api/src/services/subscriptions.FEATURE.md). All of it is exercised in DEV against Stripe test mode; the live-mode Stripe account and the FacturAPI organization with its CSD certificates are a launch gate, not a follow-up. Two fiscal documents stay manual by decision — the monthly factura global and the CFDI de egreso netting a partial refund — and Stripe Tax / IVA presentation is separately open. Still open with no direction: WhatsApp Business / SMS lead capture for the Mexican market, external uptime monitoring, infrastructure alert policies, and a public status page.

The sequence is clear the cutover blockers before provisioning. Not all of them are the same kind of work: six prerequisites need no GCP at all, and three are still open. Done: the tiger/topology schema drift, the failed-job retry scripts, the rate sanity-check, and the docs access boundary. Still open, and none of it engineering work — escrow the fiscal-PII key, provision the live-mode Stripe account and FacturAPI organization, and turn on Knock token verification. Start those now; they run in parallel with everything else and they are exactly the kind of task that slips.

The GCS migration cannot complete before buckets exist, and the buckets cannot exist before the GCP organization does — so the next GCP step is the foundation (ADR-014), then the three buckets (rec-uploads-dev and rec-uploads-stg in us-central1, rec-uploads-prd in the NAM4 dual-region), then switch DEV over. TRUSTED_PROXY_CIDRS is provisioning config plus a verified header path, not config alone — see prerequisite 2. Full breakdown and recommended order are in docs/PRODUCTION.md §14, which also names the three controls that gate PRD taking traffic: AOF verified on Memorystore, one completed Cloud SQL restore drill, and the fiscal-PII key escrowed with its restore rehearsed.