Preview environments (per-PR)
M16 gives each PR to main an isolated, browsable preview environment. This page covers the
per-PR isolated database (M16-1, #169), the per-PR API preview that consumes it (M16-2,
#170), the per-PR web preview whose /api proxy targets that API (M16-3, #171), the per-PR docs
preview that renders the PR's Docusaurus site (M45), and the teardown on PR close (M16-4,
#172). The create side lives in one workflow,
preview.yml, as the provision, deploy-server,
deploy-web, and deploy-docs jobs (plus a comment job that posts the URLs back to the PR);
teardown is its own workflow,
preview-teardown.yml, on the PR-close event.
This is a different lane from the pre-merge e2e run:
e2e-preview.yml already isolates the automated-test
path with an in-memory store that dies with the job. The preview database exists for the
persistent path - a deployed per-PR stack a human can click around in - without ever touching the
real dev/prod data.
Per-PR isolated database - the provision job
On every PR to main (and on manual dispatch with a PR number), the job:
- Creates a Neon branch database named
preview/pr-<number>on the project referenced by theNEON_PROJECT_IDsecret, vianeondatabase/create-branch-action@v6. A Neon branch is copy-on-write - it starts as a pointer to the parent's storage, so it is ready in seconds and costs nothing until written to. Idempotent: a re-run (a new push to the PR) reuses the existing branch (created=false) instead of erroring or duplicating. - Applies the schema and seed by running
apps/server/scripts/provision-preview-db.tsagainst the branch database (see below). - Publishes the connection info for downstream jobs: the pooled URL lands in a 1-day
workflow artifact (
preview-db-pr-<number>, apreview-db.envfile withPREVIEW_DATABASE_URL= pooled andPREVIEW_DATABASE_URL_DIRECT= direct), and the branch name is a job output. The URLs embed the branch DB password, so they are masked and never printed to logs; the artifact is the hand-off (thedeploy-serverjob consumes it).
Why Neon (not Supabase branching)
Bloom's data layer is plain Postgres over asyncpg - no GoTrue, PostgREST, RLS, or storage;
auth is Bloom's own OIDC + test-login. So the preview DB can be any Postgres. Supabase branching
is a Pro-plan-only feature (~$25/mo floor) plus ~$0.32/day per open PR; Neon's free tier gives
copy-on-write branching, official create/delete GitHub Actions, and scale-to-zero compute at no
cost for this usage. Prod stays on Supabase, untouched - the preview path stops depending on it.
How schema is applied (what we found, and reuse)
Bloom has no separate migrations tool: the API creates its schema idempotently at boot - the
server's boot path connects the Postgres-backed stores (bloom_runs, bloom_jobs,
bloom_runtime_correlations, and the account tables), each of which runs CREATE TABLE IF NOT EXISTS (see persistence.md). The provision script reuses exactly that mechanism -
the same store classes, the same shared-pool wiring - rather than inventing a parallel migration
system. The branch DB is therefore ready before the API preview first boots, and the API's own boot
re-asserts the schema harmlessly when #170 points BLOOM_SUPABASE_DB_URL at it.
The seed is minimal and deterministic, with no real or PII data: the same e2e-user@bloom.test
identity the non-production test-login route (POST /api/auth/test-login) upserts, created through
the same upsert_user_from_oauth path so re-runs converge on one user.
Setup (already configured)
- Repo secrets
NEON_API_KEYandNEON_PROJECT_ID- a Neon API key and the preview project's ID. No paid plan and no dashboard/OAuth step: free-tier branching, driven entirely from CI.
Cost
A Neon branch is copy-on-write (no storage duplicated at creation) and its compute scales to zero
after 5 minutes idle (suspend_timeout in the workflow), waking in ~1s on the next query. Under
the free tier (0.5 GB storage, 100 compute-hours/project/month, 10 branches) short-lived preview
branches sit at $0/month for this usage. Branches persist until deleted - the teardown
workflow (see below) deletes the branch on PR close via the Neon REST API.
Per-PR server preview - the deploy-server job
After provision succeeds, the deploy-server job gives the PR a reachable HTTPS API running that
PR's code against that PR's branch database:
- Builds the PR's server image from the same
apps/server/Dockerfileand gha build cache asci-build.yml, and pushes it to the Artifact Registry preview repo taggedbloom-server:pr-<number>(ci-build's GHCR push stays main-only and untouched; thepr-<n>tag is overwritten on every push to the PR). - Deploys it to Cloud Run as one service per PR, named
bloom-server-pr-<number>- so a re-run updates the same service in place (a new revision, never a duplicate) and teardown deletes it by name withgcloud run services delete.--min-instances=0 --max-instances=1scales the preview to zero when idle. Auth uses the officialgoogle-github-actions/auth+setup-gcloudactions with theGCP_SA_KEYservice-account key. - Points it at the branch DB:
BLOOM_STORE_BACKEND=supabasewithBLOOM_SUPABASE_DB_URLset to the pooledPREVIEW_DATABASE_URLfrom theprovisionartifact (re-masked in this job before use - masks do not carry across jobs). The API's boot re-asserts the schema harmlessly (see above). The rest of the env mirrors the e2e-preview lane:BLOOM_ENV=development,BLOOM_LLM_PROVIDER=fake(deterministic, no real keys - never prod secrets), andBLOOM_ENABLE_TEST_LOGIN=trueso a human can sign in to the browsable preview. Because the preview URLs are publicly reachable (unauthenticated Cloud Run) even though the repo is private, test-login is hardened behind a per-PR throwaway password (BLOOM_TEST_LOGIN_PASSWORD, generated fresh each deploy and masked in logs): signing in requires the value from the sticky PR comment - a collaborator-only surface - sent as theX-Bloom-Test-Login-Passwordheader (the login page prompts for it). The data remains throwaway per-PR, and teardown removes it on PR close. - Publishes the URL for downstream (#171) after polling
/health(tolerating the scale-to-zero cold start): as the job outputserver-urland as a 1-day workflow artifact (preview-server-pr-<number>, apreview-server.envfile withPREVIEW_SERVER_URL,PREVIEW_PR, andPREVIEW_TEST_LOGIN_PASSWORDfor the comment job).
Runtime service account
The service runs as the deployer SA (bloom-ci-deployer@<project>.iam.gserviceaccount.com) on
purpose: the project has no Compute default SA (the Compute API is not enabled) and the deployer
cannot create service accounts. This is acceptable for previews because they are ephemeral (torn
down by #172) and the Bloom API makes no Google Cloud API calls at runtime, so the SA's roles are
never exercised by the app. Do not reuse this shortcut for a long-lived environment.
Setup (already configured)
- Repo secrets
GCP_SA_KEY(thebloom-ci-deployerservice-account JSON key, rolesrun.admin+artifactregistry.admin+serviceAccountUser),GCP_PROJECT_ID,GCP_REGION, andGCP_AR_REPO(the Artifact Registry docker repo thepr-<n>images land in).
Cost
A preview service scales to zero when idle and caps at one instance, so Cloud Run charges only for
actual request-handling time on an open PR - effectively $0/month within the always-free tier
for this usage. Images accumulate one pr-<n> tag per open PR (overwritten per push) in Artifact
Registry; the teardown workflow (see below) deletes both the service and the image tag on PR
close.
Per-PR web preview - the deploy-web job
After deploy-server succeeds, the deploy-web job publishes that PR's dashboard talking to
that PR's API - the human-facing entry point to the whole preview stack:
- Builds the PR's web bundle with the same toolchain and steps as
deploy-web-dev.yml: same-origin (VITE_API_BASE_URLempty), so the browser talks only to the Pages origin and the/apiPages Function (apps/web/functions/api/[[path]].ts) reverse-proxies to the API, keeping the session cookie first-party. - Points the proxy at the PR's API by baking the origin into the build. The proxy normally
resolves the
API_ORIGINPages env var, but Pages env vars are scoped per project (production/preview), not per deployment - every PR preview would share one value. So the job overwrites the committed null constant inapps/web/preview-api-origin.ts(which the proxy prefers overAPI_ORIGIN) with thePREVIEW_SERVER_URLfrom thepreview-server-pr-<n>artifact, and the value ships inside that deployment's Function bundle. No per-PR dashboard step; dev/production deploys keep the committed null and resolveAPI_ORIGINexactly as before. - Deploys to the existing Cloudflare Pages project as a preview, via the same
cloudflare/wrangler-action@v3pattern as the dev deploy but with--branch=pr-<n>. That makes it a preview deployment (the project's production branch ismain) with the stable branch aliashttps://pr-<n>.<project>.pages.dev. Idempotent: a re-run redeploys the same branch and the alias always points at the latest build. Teardown removes thesepr-<n>deployments on PR close - see theteardown-pagesjob below. - Verifies end-to-end and publishes the URL: polls
<preview>/api/auth/methrough the deployed Function until the PR's API answers (401 unauthenticated is the expected success - the API's health route is not under/api, and a broken proxy target surfaces as a Function 5xx), tolerating alias propagation and the API's scale-to-zero cold start. The URL lands in the job outputweb-urland the 1-day artifactpreview-web-pr-<n>(preview-web.envwithPREVIEW_WEB_URLandPREVIEW_PR).
To click around a preview: open the alias URL, sign in via the test-login path (the API runs with
BLOOM_ENABLE_TEST_LOGIN=true), and everything under /api hits bloom-server-pr-<n> on Cloud Run
against the preview/pr-<n> Neon branch - never the dev API or dev data.
Setup (already configured)
Reuses the dev web deploy's Cloudflare setup unchanged: the CLOUDFLARE_API_TOKEN secret and the
CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_PAGES_PROJECT repo vars. No new Pages project, token, or
env var.
Cost
Pages preview deployments are part of the existing free-plan project (500 builds/month quota is untouched - the bundle is built in CI and only uploaded); the alias serves static assets from the edge and the Function invocations sit well inside the free tier for preview traffic.
Per-PR docs preview - the deploy-docs job
Alongside the app, each PR also gets a preview of the docs site the PR produces - the canonical
docs/ tree plus the generated TypeDoc reference, rendered by the apps/docs Docusaurus app (M45).
Because the docs site is fully static (no API, no database), deploy-docs is independent of the
provision -> deploy-server -> deploy-web chain and runs in parallel with it.
- Builds the PR's docs bundle with the same toolchain and steps as
deploy-docs-dev.yml(pnpm run buildinapps/docs, which also generates the TypeDoc reference); Docusaurus emits toapps/docs/build. - Deploys to the existing
bloom-docsPages project as a preview, via the samecloudflare/wrangler-action@v3pattern as the dev docs deploy but with--branch=pr-<n>, giving the stable branch aliashttps://pr-<n>.bloom-docs.pages.dev. Idempotent: a re-run redeploys the same branch and the alias always points at the latest build. - Verifies it serves and publishes the URL: a static site has no
/apito probe, so the job polls the preview root for a200(the app'sbaseUrlis/), tolerating alias propagation. The URL lands in the job outputdocs-urland the 1-day artifactpreview-docs-pr-<n>(preview-docs.envwithPREVIEW_DOCS_URLandPREVIEW_PR).
All three preview URLs (dashboard, API, docs) are then gathered into the single sticky
preview-environment comment on the PR.
Setup (already configured)
Reuses the dev docs deploy's Cloudflare setup unchanged: the CLOUDFLARE_API_TOKEN secret and the
CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_DOCS_PAGES_PROJECT repo vars (the bloom-docs project). No
new project, token, or env var beyond what the dev docs deploy already needs.
Teardown - the preview-teardown.yml workflow
When a PR to main closes - merged or not -
preview-teardown.yml destroys the PR's preview
resources so nothing lingers or accrues cost. It also runs on manual dispatch with a PR number,
for a stale preview whose close event was missed. Three independent jobs (so a failure in one lane
never leaves another lane's resources behind):
teardown-dbdeletes the Neon branch databasepreview/pr-<number>via the Neon REST API directly - it resolves the branch's id with a read-only list, thenDELETEs that id. It does not useneondatabase/delete-branch-action: that actionnpm i -g neonctlinto the global prefix, which failsEACCESon the non-root self-hosted/ARC runners (the runner user cannot write/usr/lib/node_modules); REST is root-free and drops the dependency (#533).teardown-apideletes the Cloud Run servicebloom-server-pr-<number>(gcloud run services delete) and the Artifact Registry image tagbloom-server:pr-<number>(gcloud artifacts docker images delete --delete-tags), using the samegoogle-github-actions/auth+setup-gcloudsetup as the deploy.teardown-pagesdeletes the PR's Cloudflare Pagespr-<number>preview deployment(s) across both Pages projects a preview stands up - the dashboard (CLOUDFLARE_PAGES_PROJECT, defaultbloom-dashboard) and the docs site (CLOUDFLARE_DOCS_PAGES_PROJECT, defaultbloom-docs) - as afail-fast: falsematrix so one project's failure never leaves the other's deployments behind. Cloudflare exposes no bulk/branch delete, so for each project the job pages through its deployments, collects the ones on thepr-<n>branch in thepreviewenvironment, and removes each via a per-deployment RESTDELETEwithforce=true(required to drop an aliased deployment). Guarded onCLOUDFLARE_ACCOUNT_ID, so a fork/config without Cloudflare is a clean skip rather than a failure.
Idempotency
Re-running the teardown - or closing a PR that never had a preview (e.g. preview.yml failed or
never ran) - is a clean no-op, never an error. Every delete is gated on an existence check first:
the Neon job lists the project's branches to resolve the id and skips the DELETE when
preview/pr-<n> is absent (an empty id), the gcloud deletes each describe their target first and
treat only "not found" as the no-op, and the Pages job's per-deployment DELETE set is empty when
no pr-<n> deployment matched. Check-then-delete rather than delete || true on purpose: a real
failure (auth, quota) still fails the job loudly instead of silently orphaning resources.
Teardown shares preview.yml's per-PR concurrency group, so a close event that lands while a
preview run is still provisioning queues behind it instead of racing it.
Why the Pages deployments are torn down
Earlier the Cloudflare Pages pr-<n> preview deployment was left in place - it is an inert static
upload with no compute cost once the PR's API and DB are gone. But leaving it accretes stale
deployments whose pr-<n> alias keeps resolving: a hollow frontend pointing at an already-deleted
server, which reads as "live" but is not. So teardown-pages now removes them too (see above),
via per-deployment REST DELETEs since Cloudflare offers no official bulk/branch delete action.
Teardown needs no new setup and no DB connection URL - it reuses the same repo secrets and vars the
create side already uses: NEON_API_KEY, NEON_PROJECT_ID, GCP_SA_KEY, GCP_PROJECT_ID,
GCP_REGION, GCP_AR_REPO, and the Cloudflare CLOUDFLARE_API_TOKEN secret plus the
CLOUDFLARE_ACCOUNT_ID / CLOUDFLARE_PAGES_PROJECT vars.