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
| Component | Service | Naming Convention |
|---|---|---|
| Database | Neon Branch — off the Staging (non-PHI) project | pr-<number> |
| Backend API | GCP Cloud Run | api-pr-<number> |
| Frontend UI | GCP Cloud Run | web-app-pr-<number> |
| Orchestration | GitHub 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
| Secret | Purpose |
|---|---|
GCP_PROJECT_ID | Google Cloud Project ID |
GCP_REGION | Target region (e.g., us-west1) |
NEON_API_KEY | Neon API key for branch create/delete + JWKS registration |
NEON_PROJECT_ID | Reference 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
| Step | Details |
|---|---|
| Authenticate | google-github-actions/auth with Workload Identity Federation |
| Build + Push | Docker image → Artifact Registry |
| Migrations | Apply 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) |
| Deploy | gcloud run deploy api-pr-${{ github.event.number }} |
Environment variables injected into Cloud Run:
| Variable | Source |
|---|---|
DATABASE_URL | Job 1 output (Neon branch) |
BETTER_AUTH_JWKS_URL | Job 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.
| Step | Details |
|---|---|
| Authenticate | google-github-actions/auth with Workload Identity Federation |
| Build + Push | Docker image → Artifact Registry — VITE_* values passed as build args (inlined into the client bundle) |
| Deploy | gcloud run deploy web-app-pr-${{ github.event.number }} with --ingress=internal-and-cloud-load-balancing (server container, no public URL) |
| Publish to tailnet | Add 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):
| Variable | Source |
|---|---|
VITE_API_URL | Job 2 output (API Cloud Run URL) |
VITE_BETTER_AUTH_URL | Branch-scoped Better Auth base URL |
Runtime environment variables injected into Cloud Run:
| Variable | Source |
|---|---|
DATABASE_URL | Job 1 output (the PR's Neon branch) — Better Auth + Drizzle connect here |
BETTER_AUTH_SECRET | Per-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 --quietCleanup 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)
- Dynamic API URLs. All client API calls use
VITE_API_URL— never hardcodelocalhostor a staging domain. - Server container, not a static
dist/. The web-app hosts Better Auth + Drizzle, so it ships as a server container that readsDATABASE_URL+BETTER_AUTH_SECRETat 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)
-
Dynamic CORS. The CORS middleware accepts requests from the ephemeral
web-app-pr-*.run.appdomains 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. -
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; theschema-diff-actioncomment 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:
- CI/CD — Production deployment workflows
- Environment Variables — Master env var reference
- Infrastructure — Pulumi IaC for production GCP resources