PR Preview Environments

Ephemeral PR Preview Environments

Every pull request automatically gets an isolated, full-stack environment — a dedicated applications UI (Vite + TanStack), Python API, and Neon database branch (with isolated auth) — that is torn down when the PR closes.


Architecture Overview

ComponentServiceNaming Convention
DatabaseNeon Branch — off the Staging (non-PHI) projectpr-<number>
Backend APIGCP Cloud Runapi-pr-<number>
Frontend UIGCP Cloud Runweb-app-pr-<number>
OrchestrationGitHub Actions + Neon actions + gcloud CLI

Because Better Auth stores its tables in Neon, each Neon branch automatically gets an isolated auth environment — the auth tables branch with the database, at zero extra infra. Pulumi is not required for PR environments; the Neon branch actions + gcloud run deploy handle ephemeral resources natively.

What's in a preview (and what isn't). A PR env deploys only the web-app (server), the api, and a Neon branch. The marketing site (public, PHI-free, no per-PR data) and the worker + Restate runtime (durable-execution infra, not branch-scoped) are not spun up per PR.

Previews are tailnet-only. The web-app-pr-<n> / api-pr-<n> services deploy with internal ingress (no public *.run.app) behind the shared internal load balancer, reachable at *.pr.internal only from the company tailnet (Tailscale). Feature previews are non-PHI, but the tailnet keeps unreleased work private and is the same access plane as Coder. See Infrastructure › Tailnet Access.


GitHub Actions Secrets

SecretPurpose
GCP_PROJECT_IDGoogle Cloud Project ID
GCP_REGIONTarget region (e.g., us-west1)
NEON_API_KEYNeon API key for branch create/delete + JWKS registration
NEON_PROJECT_IDReference ID of the Neon project

GCP authentication uses GitHub OIDC + Workload Identity Federation via google-github-actions/auth. No long-lived GCP service-account JSON keys are required. Per-PR runtime secrets are minted in CI and materialized into GCP Secret Manager (see Secret Management).


Spin-Up Workflow — pr-env-up.yml

Trigger: pull_request on types [opened, synchronize, reopened], scoped to base: dev (branches: [dev]). Only PRs targeting dev — i.e. feature/* work — spin up a Staging preview. release/* / hotfix/* PRs (which target main) get no preview env; they verify in-boundary against a Neon Production branch instead. See Secure PHI GitOps.

Concurrency: Cancel in-progress runs for the same PR to avoid race conditions.

Job 1 — Database Provisioning (Neon)

Uses neondatabase/create-branch-action to create a branch scoped to the PR off the non-PHI Staging project — feature branches never fork production. See Secure PHI GitOps for the dual-project (Staging non-PHI / Production PHI) model:

- uses: neondatabase/create-branch-action@v5
  with:
    project_id: ${{ secrets.NEON_PROJECT_ID }}
    branch_name: pr-${{ github.event.number }}
    api_key: ${{ secrets.NEON_API_KEY }}

Outputs passed to subsequent jobs:

  • Branch Postgres connection string (DATABASE_URL)
  • Branch-scoped Better Auth config (JWKS URL registered against this branch via the Neon API)

Job 2 — Backend Deployment (Python API)

Needs: Job 1

StepDetails
Authenticategoogle-github-actions/auth with Workload Identity Federation
Build + PushDocker image → Artifact Registry
MigrationsApply schema (Drizzle Kit, from the top-level schema/ dir) to the Neon branch; post the neondatabase/schema-diff-action diff as a PR comment (the eng review gate)
Deploygcloud run deploy api-pr-${{ github.event.number }}

Environment variables injected into Cloud Run:

VariableSource
DATABASE_URLJob 1 output (Neon branch)
BETTER_AUTH_JWKS_URLJob 1 output (branch-scoped)

The service deploys with --ingress=internal-and-cloud-load-balancing (no public URL) and is registered as a serverless NEG + host rule api-pr-<n>.pr.internal on the internal ALB — reachable only on the tailnet (see Tailnet Access). Its internal hostname is passed to Job 3.

PR preview environments must never host real PHI. Feature branches fork the Staging (non-PHI) Neon project — which holds only masked / synthetic data — so a preview branch structurally cannot contain PHI. See Secure PHI GitOps.

Job 3 — Web App Deployment (Vite + TanStack server)

Needs: Job 1, Job 2

The web-app is a server, not a static bundle — it hosts Better Auth + Drizzle and connects to Neon at runtime. So it is built and deployed as a server container carrying both build-time (VITE_*) and runtime (DATABASE_URL / BETTER_AUTH_SECRET) config.

StepDetails
Authenticategoogle-github-actions/auth with Workload Identity Federation
Build + PushDocker image → Artifact Registry — VITE_* values passed as build args (inlined into the client bundle)
Deploygcloud run deploy web-app-pr-${{ github.event.number }} with --ingress=internal-and-cloud-load-balancing (server container, no public URL)
Publish to tailnetAdd a serverless NEG + URL-map host rule web-app-pr-<n>.pr.internal on the internal ALB — reachable only on the tailnet (Tailnet Access)

Build arguments (inlined at build time):

VariableSource
VITE_API_URLJob 2 output (API Cloud Run URL)
VITE_BETTER_AUTH_URLBranch-scoped Better Auth base URL

Runtime environment variables injected into Cloud Run:

VariableSource
DATABASE_URLJob 1 output (the PR's Neon branch) — Better Auth + Drizzle connect here
BETTER_AUTH_SECRETPer-PR secret minted in CI, materialized into Secret Manager

Build-time vs. runtime: values referenced from client-side code via VITE_* are inlined at build time — pass them as Docker build arguments (the marketing site's NEXT_PUBLIC_* vars behave identically). But because web-app is a server, its server-only secrets (DATABASE_URL, BETTER_AUTH_SECRET) are injected as runtime Cloud Run env vars, never baked into the image.

Job 4 — PR Comment

Needs: Job 3

Posts (or updates) a sticky comment with the preview URLs:

🚀 Preview Environment Ready  (tailnet-only — connect via Tailscale)
- UI:       https://web-app-pr-42.pr.internal
- API:      https://api-pr-42.pr.internal
- Database: Neon branch `pr-42`

Teardown Workflow — pr-env-down.yml

Trigger: pull_request on types [closed] (fires for merged and unmerged PRs).

Cleanup runs in parallel:

Cleanup GCP

# Remove the tailnet host rules + serverless NEGs from the internal ALB first, then the services
gcloud compute url-maps remove-host-rule <internal-lb-url-map> --host=web-app-pr-${{ github.event.number }}.pr.internal --quiet || true
gcloud compute url-maps remove-host-rule <internal-lb-url-map> --host=api-pr-${{ github.event.number }}.pr.internal --quiet || true
gcloud run services delete api-pr-${{ github.event.number }} --region=$GCP_REGION --quiet
gcloud run services delete web-app-pr-${{ github.event.number }} --region=$GCP_REGION --quiet

Cleanup Neon

- uses: neondatabase/delete-branch-action@v3
  with:
    project_id: ${{ secrets.NEON_PROJECT_ID }}
    branch: pr-${{ github.event.number }}
    api_key: ${{ secrets.NEON_API_KEY }}

Deleting the branch also disposes of its isolated auth data and any per-PR secrets.


Application Code Requirements

Web app (Vite + TanStack server)

  1. Dynamic API URLs. All client API calls use VITE_API_URL — never hardcode localhost or a staging domain.
  2. Server container, not a static dist/. The web-app hosts Better Auth + Drizzle, so it ships as a server container that reads DATABASE_URL + BETTER_AUTH_SECRET at runtime (from the PR's Neon branch + the minted per-PR secret). VITE_* values are baked in as build args; server secrets stay runtime env vars.

Python (Backend)

  1. Dynamic CORS. The CORS middleware accepts requests from the ephemeral web-app-pr-*.run.app domains via a strict, anchored regex:

    allow_origin_regex=r"^https://web-app-pr-\d+\.pr\.internal$"

    [!WARNING] Never use a wildcard (*) for CORS — sessions and tokens must be protected even in PR environments.

  2. Database migrations. Run migrations (Drizzle Kit, from the top-level schema/ dir) against the Neon PR branch on startup or as a distinct pre-deploy step; the schema-diff-action comment is the human review gate for schema + RLS changes.


Workflow File Organization

.github/workflows/
├── ci.yml                # PR quality gate (lint, test, type-check, build) — targets dev / release/*
├── deploy-api.yml        # Cloud Run deployment — backend/api (FastAPI), filter backend/api/**
├── deploy-worker.yml     # Cloud Run deployment — backend/worker (Restate handlers), filter backend/worker/**
├── deploy-web-app.yml    # Cloud Run deployment — ui/web-app (Vite), filter ui/web-app/**
├── deploy-marketing.yml  # Cloud Run deployment — ui/marketing (Next.js), filter ui/marketing/**
├── pr-env-up.yml         # Ephemeral PR environment spin-up
├── pr-env-down.yml       # Ephemeral PR environment teardown
└── dbt.yml               # Nightly Neon → BigQuery dbt sync

See also: