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.

Lifecycle of a pull-request preview environment Opening a pull request triggers an ephemeral build and deploy to a unique branch-scoped URL; quality checks run against that URL and report back to the pull request; merging promotes the change and tears the preview environment down. open PR → ephemeral deploy → unique URL → checks → teardown Open PR pull_request opened / sync Ephemeral deploy build + push Unique URL branch-scoped subdomain Checks Lighthouse a11y · E2E Teardown on merge / close Checks report back to the PR; failures block merge branch protection gates the merge button
Opening or updating a PR triggers an ephemeral deploy to a unique URL; checks run against that URL and report back to gate the merge; merging or closing the PR tears the environment down.

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 scenarioPreview build timeNotes
Cold, no cache4m05severy dependency and image rebuilt
Warm, shared cache key1m40sbut risks serving another branch's artifacts
Warm, per-branch cache key70scorrect 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 noindex and 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 noindex so 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.

Static Site Generators in Production