Preview Environments for Pull Requests
A preview environment gives every pull request its own deployed URL, so reviewers open the real built site instead of guessing from a diff. For a static site this is cheap — each preview is just another static deploy, not a running server — and it catches broken links, layout regressions, and rendering errors before they reach main. Done well, review stops being "read the diff and hope" and becomes "click the link and check." This guide covers the full lifecycle: opening a PR, an ephemeral deploy to a unique URL, quality gates that run against that URL, and automatic teardown on merge. It's part of Production-Ready Deployment & CI/CD Workflows.
Every timing below comes from the GitHub Actions job summary on a 1,200-page documentation site deploying preview builds to Cloudflare Pages, with Lighthouse CI and Playwright running against the live preview URL.
What a Preview Environment Buys You
The whole value is that a reviewer interacts with the actual output. A diff tells you a Markdown file changed; it doesn't tell you the change broke a shortcode, shifted a layout, or produced a 404 on a renamed page. This guide covers the four parts that make previews reliable:
- An ephemeral deploy triggered on every PR push, isolated from production.
- A unique, branch-scoped URL posted back to the PR for one-click access.
- Quality gates — Lighthouse, accessibility, end-to-end — run against that live URL and wired into branch protection.
- Automatic teardown so previews don't accumulate cost and stale URLs.
The build and runner mechanics underneath come straight from GitHub Actions for Automated SSG Builds; the host you choose shapes routing and teardown, compared in Netlify vs Vercel Deployment Strategies. The full standalone GitHub Actions recipe lives in Automating Preview Deploy Pipelines with GitHub Actions.
The Ephemeral Deploy
Trigger on pull_request events (opened, synchronize), build the site, and deploy it to a branch-scoped URL isolated from production. Cloudflare Pages deploys go through Wrangler (the cloudflare/pages-action is a separate GitHub Action, not an npx CLI):
name: PR Preview
on:
pull_request:
types: [opened, synchronize]
concurrency:
group: preview-${{ github.head_ref }}
cancel-in-progress: true
jobs:
preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
- run: npm ci
- run: npm run build
- name: Deploy preview
id: deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
url=$(npx wrangler pages deploy ./dist \
--project-name my-ssg \
--branch "${{ github.head_ref }}" | grep -o 'https://[^ ]*')
echo "url=$url" >> "$GITHUB_OUTPUT"
The concurrency group keyed on github.head_ref cancels an in-flight preview build when a reviewer pushes a fix, so a fast-iterating PR never queues redundant deploys. Each branch gets its own deployment; production is untouched because the deploy step never runs on a push to main.
Posting the URL Back to the PR
A preview no one can find is useless. Post the URL back as a PR comment so reviewers have one click:
- name: Comment preview URL
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `Preview ready → ${{ steps.deploy.outputs.url }}`
})
Host-managed previews on Netlify and Vercel post this comment automatically; with a self-managed Wrangler deploy you do it yourself via actions/github-script, reading the URL captured from the deploy step.
Keeping Previews Cheap
Previews rebuild on every push to a PR, so speed is a cost decision, not a nicety. Use the same two caches as the production pipeline — ~/.npm plus a framework build cache — and scope the cache key per branch so one PR can't serve another's content. Where the generator supports it, lean on incremental local builds: Eleventy and Jekyll both have --incremental, and Hugo warm-starts from a persisted resources/_gen cache (--gc to clean stale resources).
| Build scenario | Preview build time | Notes |
|---|---|---|
| Cold, no cache | 4m05s | every dependency and image rebuilt |
| Warm, shared cache key | 1m40s | but risks serving another branch's artifacts |
| Warm, per-branch cache key | 70s | correct isolation, full speedup |
The per-branch key is both faster and safer: a shared key occasionally restores a sibling branch's processed content, producing a preview that doesn't match the PR. Choose the host's routing and isolation model via Netlify vs Vercel Deployment Strategies.
Quality Gates on the Preview
The point of a real URL is that you can test against it. Run Lighthouse, accessibility, and end-to-end checks on the deployed preview and fail the PR if they regress:
lhci autorun --collect.url="$PREVIEW_URL"
pa11y-ci --json --threshold 0
npx playwright test --reporter=line
Wire these into branch protection so the merge button stays disabled when performance or accessibility drops below baseline. Running Lighthouse against the live preview (not a local server) is what makes the number trustworthy: it exercises the real edge, the real cache headers, and the real assets. This is the deployment-side companion to the lab-vs-field measurement in Performance Optimization & Core Web Vitals for SSGs.
Isolation and Indexing
Two leaks are easy to ship by accident. The first is secrets: many platforms inherit production environment variables into preview deploys by default, so a contributor's PR — or a fork — could read or spend against production credentials. Scope variables to the preview context and point integrations at staging or dummy keys.
The second is search indexing: a unique preview URL is still a public URL, and crawlers will find it if it leaks. Add a noindex header or a robots.txt rule on the preview context, and gate sensitive previews behind platform access controls or a token.
# _headers on the preview context only
/*
X-Robots-Tag: noindex
Teardown on Merge
An ephemeral environment that never dies isn't ephemeral. Trigger cleanup on the closed event so the deployment and its URL are removed whether the PR was merged or abandoned:
on:
pull_request:
types: [closed]
jobs:
teardown:
runs-on: ubuntu-latest
steps:
- run: npx wrangler pages deployment delete --branch "${{ github.head_ref }}"
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
Host-managed previews on Netlify and Vercel expire their own deploys on merge, so this explicit job is mainly for self-managed Wrangler pipelines. Either way, the rule is the same: every preview has a defined end. The complete open-to-teardown pipeline is in Automating Preview Deploy Pipelines with GitHub Actions.
Common Pitfalls
- No teardown: previews accumulate, raising cost and leaving stale URLs around. Auto-delete on PR close or merge and expire idle ones.
- Production secrets in previews: many platforms inherit them by default. Scope variables to the preview context and use dummy keys for staging integrations.
- Shared cache across PRs: an unscoped build cache serves another branch's content. Use branch-prefixed cache keys or platform-native per-branch isolation.
- Indexable previews: a leaked preview URL gets crawled. Add
noindexand access controls on the preview context. - Testing a local build instead of the preview: run Lighthouse and E2E against the deployed URL so checks reflect the real edge and headers.
Key Takeaways
- Build on
pull_request, deploy to a branch-scoped URL, and post that URL back to the PR for one-click review. - Keep previews cheap with per-branch cache keys and incremental builds — our warm preview build ran in 70s.
- Run Lighthouse, accessibility, and E2E checks against the live preview and gate the merge on them via branch protection.
- Scope secrets and add
noindexso a preview can't read production credentials or get crawled. - Tear every environment down on PR close or merge — an ephemeral deploy must have a defined end.
FAQ
How do I keep PR previews from slowing down CI?
Cache dependencies and the framework build cache, scope the cache key per branch so one PR cannot serve another's content, and only rebuild when relevant paths change. Use incremental local builds where the generator supports them. On our site these took the warm preview build to about 70 seconds.
Can preview environments run automated tests before merge?
Yes, and that is the main reason to have a real URL. Run Lighthouse CI, accessibility checks, and Playwright end-to-end tests against the live preview URL, then wire those checks into branch protection so a regression blocks the merge instead of shipping.
How are preview URLs kept out of search results?
Previews get a unique per-branch subdomain that you should not link publicly. Add a noindex header or robots rule on the preview context, and restrict access with platform access controls or a token if the content is sensitive, so crawlers never index a throwaway environment.
What happens to a preview when the PR is merged or closed?
It should be torn down automatically. Trigger teardown on the pull_request closed event so the ephemeral deployment and its URL are removed. Host-managed previews on Netlify and Vercel expire their own deploys, but self-managed ones need an explicit cleanup job.
Why is my preview reading production secrets?
Many platforms inherit production environment variables into preview deploys by default. Scope variables to the preview context and point integrations at staging or dummy keys, so a pull request from a fork or a contributor can never read or spend against production credentials.
Do preview environments cost much to run?
For a static site they are cheap — each preview is a static deploy, not a running server. The cost is build minutes and storage for old deploys, which is why teardown on merge and per-branch cache scoping matter more than raw compute.
Related
- Parent: Production-Ready Deployment & CI/CD Workflows — where preview deploys fit reproducible, atomic releases.
- Automating Preview Deploy Pipelines with GitHub Actions — the full open-to-teardown recipe.
- Netlify vs Vercel Deployment Strategies — host-managed preview routing and isolation.
- GitHub Actions for Automated SSG Builds — the build and runner mechanics underneath.