Infrastructure

Infrastructure as Code — infra/ (Pulumi, TypeScript)

Infrastructure is managed using Pulumi with TypeScript, spanning Google Cloud Platform (compute, registry, secrets), Neon (Postgres), and Cloudflare (DNS + CDN). State lives on Pulumi Cloud — no self-managed state bucket.

Every third-party in the stack now has a first-class Pulumi provider, so there are no "manual setup notes" resources. Neon is provisioned by the Neon Pulumi provider, GCP by the Google provider, and DNS by the Cloudflare provider.


Directory Structure

infra/
├── index.ts          # Main Pulumi program — wires the modules together, stack exports
├── gcp.ts            # Cloud Run (web-app/marketing/api/worker), Load Balancing + Cloud Armor, GCS, Artifact Registry, Secret Manager, WIF, BigQuery
├── restate.ts        # Restate Cloud wiring — environment config + request-signing key (Secret Manager); the runtime itself is managed off-cluster
├── neon.ts           # Neon project (Scale, hipaa:true), branches, roles
├── cloudflare.ts     # DNS records, proxied CDN endpoints
├── tailscale.ts      # Tailscale subnet router (GCE VM) + internal ALB — non-prod tailnet access
├── Pulumi.yaml       # Project configuration
├── Pulumi.dev.yaml   # Dev stack config (placeholder values)
└── package.json      # Pulumi + provider dependencies

State Backend — Pulumi Cloud

Pulumi state and secrets encryption are hosted on Pulumi Cloud (app.pulumi.com). This gives the team drift detection, per-stack history, and a shared encryption key without operating a state bucket.

StackPurpose
prodProduction GCP + Neon + Cloudflare
devShared non-prod resources

Per-PR preview resources (Cloud Run web-app-pr-<n> / api-pr-<n> and the Neon pr-<n> branch) are not managed by Pulumi — they are created and destroyed directly by GitHub Actions. See PR Environments.


GCP Resources — gcp.ts

Cloud Run Services

Four services, all containers from Artifact Registry:

ServiceSourceNotes
web-appui/web-app (Vite + TanStack)Applications UI; hosts Better Auth + Drizzle → Neon
marketingui/marketing (Next.js 14)Public marketing site; no auth, no PHI
apibackend/api (FastAPI)Python HTTP API → Neon
workerbackend/worker (Restate handlers)Durable-execution / async job handlers → Neon; invoked by Restate Cloud over signed HTTPS, suspend-to-zero between steps
SettingValueRationale
maxScale10Cost control; adjust based on load
timeoutSeconds3600worker only — 60-minute ceiling for long WellSky sync steps. web-app / api / marketing keep normal request timeouts (default 300s).
Image sourceArtifact RegistrySame-project, no cross-project IAM needed

Cloud Run IAM

  • Public invocation is allowed for the marketing site, the /health liveness probe, and the worker's Restate ingress path only. The worker endpoint is public at the Cloud Run IAM layer because Restate Cloud is an off-GCP caller — it is authenticated in the handler by verifying Restate Cloud's request signature, not by Cloud Run IAM.

Production: Lock down Cloud Run IAM with proper authentication (--no-allow-unauthenticated, access via IAM / IAP). The open /health endpoint is illustrative only.

Restate Cloud wiring — restate.ts

The Restate durable-execution runtime is Restate Cloud — a fully managed, serverless Restate environment (region us), not a self-hosted server. There is no GCE VM, no persistent disk, and no Rust node to operate: Restate Cloud holds the durable state and drives execution, invoking the worker's HTTP handlers and suspending them to zero between steps. restate.ts therefore provisions no compute — it only wires the connection: the Restate Cloud environment endpoint/region and the request-signing public key, materialized into Secret Manager for the worker.

  • Runtime: managed by Restate Cloud off-GCP. Because it is serverless there is no always-on node in our VPC to size, patch, or pay for at idle.
  • Invocation: Restate Cloud calls the worker's public HTTPS endpoint and each request is signed with the environment key; the worker verifies the signature in-handler. Handlers suspend between steps, so worker still scales to zero when idle.
  • Config: the worker service reads RESTATE_* (Restate Cloud environment endpoint + identity/signing keys) from Secret Manager. See Environment Variables and Backend.

No PHI is shared with Restate Cloud (initial posture). Any PHI that must be journaled is encrypted at the serde boundary inside the handler (GCP-KMS-backed encrypting Serde), so Restate Cloud's journal and virtual-object state hold ciphertext only, and the primary entity identifiers are opaque, short-lived IDs — so keys, trace IDs, and call structure stay PHI-free and non-correlatable. Restate Cloud is therefore treated as an encrypted conduit and needs no vendor BAA. See HIPAA Compliance and Backend.

Artifact Registry

  • Docker image repository for the web-app, marketing, api, and worker services.
  • Images are tagged with commit SHA for traceability.

Secret Manager

  • Runtime store for application secrets, materialized from Pulumi ESC and mounted into Cloud Run at deploy time. See Secret Management.

Workload Identity Federation

  • GitHub Actions authenticates to GCP via Workload Identity Federation (GitHub OIDC) — no long-lived service-account JSON keys are stored as secrets.

BigQuery Dataset

  • Name: raw_data
  • Location: US
  • Landing zone for the incremental Neon → BigQuery extract-load (see Data Pipeline).

Object Storage — GCS (user uploads / documents)

User-uploaded files and generated documents live in Google Cloud Storage, inside the trust zone:

  • CMEK — buckets are encrypted with customer-managed encryption keys (Cloud KMS), not just Google-default keys.
  • Signed URLs — clients never get direct bucket ACLs; the API mints time-limited signed URLs for upload/download so access is scoped and auditable.
  • No public buckets. Uniform bucket-level access; PHI-bearing objects stay private and are served only via signed URLs.
  • PHI-bearing uploads live in GCS inside the trust zone; the full PHI-at-rest list (Neon · GCS · Vertex AI · WellSky · the governed M365 plane) is on the HIPAA Compliance page.

Neon — neon.ts

Neon is managed by Pulumi via the Neon Pulumi provider — the project, its HIPAA posture, branches, and roles are all declarative resources:

  1. Project on the Scale plan, region AWS us-west-2 (same metro as Cloud Run us-west1 to minimize cross-cloud egress).
  2. hipaa: true set on the project (irreversible; sets audit_log_level=hipaa). The BAA is self-serve on Scale — no separate add-on.
  3. Extensions: pgvector (embeddings) and pg_session_jwt (Better Auth JWT → RLS auth.user_id()).
  4. Roles & authorization: least-privilege application role(s). Neon RLS is the enforcement floor — the last-line database guard that holds even if app code is wrong — while Cerbos (a self-hosted, stateless PDP running in-boundary) is the app-level policy decision point for richer, contextual authorization. RLS schema + migrations live in the top-level schema/ dir (owned by Drizzle Kit); Cerbos policies live in the top-level policies/ dir. RLS is the floor, not the authorization source of truth.
  5. Branches: the primary branch plus the copy-on-write branch-per-PR model — ephemeral pr-<n> branches are created by CI, not Pulumi.

The Neon Data API (PostgREST) is not enabled for PHI paths — it sits outside the HIPAA boundary. Application and API traffic use direct Postgres connections. See HIPAA Compliance.


Edge & WAF — split by PHI exposure

The edge is deliberately split so that PHI never transits a vendor we don't need a BAA with:

SurfaceFronted byWhy
Marketing site + DNS (no PHI)Cloudflare (proxied CDN + DDoS + DNS)Static, public, PHI-free — Cloudflare's CDN/WAF is a good fit and needs no BAA because no PHI flows through it.
Web app + API (carry PHI)GCP Cloud Load Balancing + Cloud Armor (WAF)Keeps the PHI path entirely inside the Google BAA boundary. PHI never transits Cloudflare, so no Cloudflare Enterprise / Cloudflare BAA is required.

Do not proxy the PHI-carrying web-app/API through Cloudflare. Terminate and protect those origins with Cloud Load Balancing + Cloud Armor so all PHI stays within GCP. Cloudflare handles only the marketing surface and DNS.

Cloudflare — cloudflare.ts

DNS and CDN are managed with the Cloudflare Pulumi provider:

  • DNS records for the apex + subdomains. Marketing points at its Cloud Run service via the Cloudflare proxy; the web-app / API hostnames are DNS-only (grey-cloud), resolving to the GCP load balancer so their traffic bypasses Cloudflare entirely.
  • Proxied (orange-cloud) endpoints for CDN + DDoS protection on the PHI-free marketing surface only.

Tailnet Access — Tailscale (non-prod dev surfaces)

Every non-production surface — per-PR previews, the shared dev / staging env, and the Coder remote-dev workspaces — is reachable only from the company tailnet (Tailscale). Production is unaffected. One tailnet is the single "dev network," so there is no second access mechanism to operate.

Shape — one internal LB, wildcard DNS, tailnet-only:

  1. Preview / staging Cloud Run services deploy with ingress = internal-and-cloud-load-balancingno public *.run.app URL. They are reachable only through the internal load balancer.
  2. One regional internal Application Load Balancer (a private VIP in the VPC) does host-based routing. A wildcard Cloud DNS private zone *.pr.internal resolves to the LB VIP, so there is no per-PR DNS record — CI adds a serverless NEG + one URL-map host rule per PR (web-app-pr-<n>.pr.internal, api-pr-<n>.pr.internal) and removes them on teardown. One LB, N backends.
  3. A Tailscale subnet router — one always-on node (small GCE VM; infra/tailscale.ts) — advertises the route to the LB VIP into the tailnet and pushes split-DNS for *.pr.internal. A Tailscale ACL scopes reachability to the dev group.
  4. Net effect: a dev on the tailnet opens https://web-app-pr-42.pr.internal; it resolves to the internal LB, host-routes to the right service — and nothing off the tailnet can reach it (the LB has no public IP; the only ingress path is the subnet router). This is network-level isolation, stronger than a public endpoint gated by IAP.

The same tailnet reaches Coder. The Coder-in-private-subnet workspaces are reached over this same tailnet — one VPN for previews, staging, and remote dev.

Tailscale carries no PHI and needs no BAA. Previews / staging run on the non-PHI Staging Neon project, so no PHI ever transits the tailnet. The one path where PHI could ride the tunnel is Coder → Neon Production; Tailscale is WireGuard, end-to-end encrypted (its DERP relays pass ciphertext only and never see plaintext), so it is treated as a HIPAA conduit (like an ISP) — no Tailscale BAA required. Never terminate PHI plaintext on a Tailscale relay/coordination hop.

Production stays public. The prod app / API remain on GCP Cloud LB + Cloud Armor, and marketing on Cloudflare — only non-prod is tailnet-gated.


Caching — Redis deferred

There is no Redis in the initial stack. The two things a cache would carry are handled in-database for now:

  • Better Auth rate-limit storage and light caching live in Neon (Postgres). Better Auth supports a database-backed rate limiter, which is sufficient at launch volumes.
  • Add Memorystore (Redis) only when real pressure appears — hot-path read latency, rate-limit write contention, or session/cache volume that Postgres shouldn't absorb. It slots in as a managed GCP resource (in-boundary) when the need is demonstrated, not preemptively.

Deferring Redis keeps one fewer moving part in-boundary. Revisit when metrics (Neon CPU on rate-limit tables, p95 read latency) justify a dedicated cache.


Resource Organization

Following the code organization principle of one concern per file:

FileResources
index.tsPulumi program entry point, module wiring, stack exports
gcp.tsAll GCP resources (Cloud Run, Cloud Load Balancing + Cloud Armor, GCS, Artifact Registry, Secret Manager, WIF, BigQuery)
restate.tsRestate Cloud wiring — environment config + request-signing key (Secret Manager); the runtime is managed off-GCP
neon.tsNeon project (Scale, hipaa:true), branches, roles, extensions
cloudflare.tsDNS records and proxied CDN endpoints

As the infrastructure grows, further split gcp.ts by service (e.g., cloudrun.ts, artifact-registry.ts, bigquery.ts, secrets.ts).

See also: