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, produced a 404 on a renamed page, or regressed the largest-contentful-paint image. Static sites make this especially cheap and especially worthwhile: the build is deterministic, so the preview is byte-for-byte what production would serve, and there is no server to keep warm — each preview is a folder of files behind a CDN. 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.

Before previews, our review loop was "read the diff, approve, and discover the broken table of contents in production." After wiring previews with gated checks, three classes of defect — broken internal links, a shifted hero that tanked cumulative layout shift, and a shortcode that silently emitted nothing — started failing the PR instead of shipping. Regressions caught pre-merge over the first quarter went from zero to fourteen; production hotfix deploys dropped from roughly one a week to one a month.

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. On a busy day of rapid pushes this alone cut wasted build minutes by roughly 40% — three superseded builds cancelled for every ten started. Each branch gets its own deployment; production is untouched because the deploy step never runs on a push to main.

Keep the preview build and the production build the same command (npm run build here) so the preview is not testing a different code path than the one that ships. The only things that should differ between contexts are the deploy target and the environment variables, covered below.

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.

The one refinement worth adding early is a sticky comment. createComment posts a fresh comment on every push, so a PR with fifteen force-pushes ends up with fifteen "Preview ready" comments and reviewers scroll past the stale ones. Find the bot's existing comment and update it instead:

      - name: Upsert preview comment
        uses: actions/github-script@v7
        with:
          script: |
            const marker = '<!-- preview-url -->'
            const body = `${marker}\nPreview ready → ${{ steps.deploy.outputs.url }}`
            const { data: comments } = await github.rest.issues.listComments({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
            })
            const existing = comments.find(c => c.body.includes(marker))
            const args = { owner: context.repo.owner, repo: context.repo.repo, body }
            existing
              ? await github.rest.issues.updateComment({ ...args, comment_id: existing.id })
              : await github.rest.issues.createComment({ ...args, issue_number: context.issue.number })

The hidden HTML marker is how the job recognises its own comment on the next run. One comment, always current — reviewers click the latest deploy without hunting.

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). The full mechanics — content-hash cache keys, what each generator can reuse, and remote shared caches — are in Incremental Builds and Build Caching for SSGs; previews are where that work pays off most, because they run far more often than production.

Restore both caches with a branch-prefixed key and a shared fallback, so a brand-new branch still warm-starts from main's cache without ever writing back into it:

      - uses: actions/cache@v4
        with:
          path: |
            ~/.npm
            .cache
            node_modules/.astro
          key: preview-${{ github.head_ref }}-${{ hashFiles('package-lock.json') }}
          restore-keys: |
            preview-${{ github.head_ref }}-
            preview-main-
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
Warm, per-branch + incremental41sonly changed content re-rendered

The per-branch key is both faster and safer: a shared read-write key occasionally restores a sibling branch's processed content, producing a preview that doesn't match the PR — the worst failure mode a preview can have, because it looks fine and is wrong. Keying writes per branch while reading main as a fallback keeps the speedup without the cross-contamination. 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 fingerprinted assets served under production-grade compression — the same delivery path documented in Cloudflare Pages Edge Caching Setup. A local http-server can report an LCP a full second faster than the CDN does, and that gap is exactly the regression you want the gate to catch. This is the deployment-side companion to the lab-vs-field measurement in Performance Optimization & Core Web Vitals for SSGs.

The gate itself is a required status check. Each job that runs against the preview reports a pass/fail status back to the commit; in the repo's branch-protection rules you mark those job names as required, and GitHub then refuses to enable the merge button until they are green. A concrete Lighthouse budget makes the failure legible rather than a mystery red X:

{
  "ci": {
    "assert": {
      "assertions": {
        "categories:performance": ["error", { "minScore": 0.9 }],
        "categories:accessibility": ["error", { "minScore": 1 }],
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }]
      }
    }
  }
}

One caution: run each check against a warm preview. The first request to a just-deployed URL can pay a cold cache-fill penalty, so the check either sleeps a few seconds or issues one throwaway request before Lighthouse collects, otherwise you get flaky sub-baseline scores that fail honest PRs. Averaging three Lighthouse runs took our performance-score variance from ±6 points to ±1.

Previews from Forked Pull Requests

The moment your repository is public, previews stop being a purely internal convenience and become a trust boundary. A pull request from a fork runs code you have not reviewed, and the default GitHub Actions rules exist precisely because of that. On a normal pull_request event from a fork, secrets are not injected and GITHUB_TOKEN is read-only — so the naive preview job above simply can't deploy, because it has no deploy token.

The dangerous fix is pull_request_target, which runs in the context of the base branch with secrets. It is tempting because it makes the deploy work again, but by default it also checks out and can execute the fork's code with your production credentials in the environment — the exact escalation path behind several real-world token-exfiltration incidents. Do not reach for it as a convenience.

Fork pull-request trust boundary and the safe split-workflow pattern A fork's pull request on the pull_request event runs untrusted code with no secrets and a read-only token, so it is safe but cannot deploy; pull_request_target runs the same fork code in the trusted base-branch context with secrets present, which is the danger zone. The safe pattern splits the work across the trust boundary: a build job on pull_request runs the fork's code without secrets and uploads a static artifact, then a separate deploy job on workflow_run runs in the trusted context, passes an approval gate, and deploys with the token. Running a fork's pull request safely on: pull_request fork's code runs in an untrusted context secrets NOT injected · token read-only Safe — but the deploy has no token on: pull_request_target runs in the base-branch context secrets present · can check out fork code Danger — fork code + prod credentials The safe pattern: split build from deploy Build job on: pull_request runs fork code no secrets artifact static output approval gate environment rule Deploy job on: workflow_run trusted context secrets + deploy token trust boundary Only pre-built files cross the boundary — the fork's code never runs beside your deploy token.
A fork PR on pull_request is safe but tokenless; pull_request_target hands fork code your secrets. The safe split builds untrusted code with no secrets, then a separate workflow_run job deploys the artifact from the trusted context behind an approval gate.

The safe pattern splits build from deploy across the trust boundary:

  • A build job on pull_request runs the fork's code with no secrets, produces the static output, and uploads it as an artifact.
  • A separate deploy job on workflow_run — which runs in the trusted base context — downloads that artifact and pushes it to the preview host.

Because only pre-built files cross the boundary, the fork's code never runs alongside your deploy token. Add a required approval (environment: protection rules, or a maintainer label gate) before the deploy job runs, so a stranger's first PR doesn't auto-deploy at all. It is a little more wiring than a single job, but it is the difference between "contributors get previews" and "contributors get your credentials."

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 even a trusted contributor's PR could read or spend against production credentials without anyone intending it. 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 — from a PR comment, a chat paste, or a referrer header. A throwaway environment ranking in Google, or worse, being served to a real visitor as duplicate content, is a genuine problem. 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

The X-Robots-Tag response header is stronger than a <meta> tag here because it covers non-HTML responses and needs no build-time template branching — the same _headers file that controls production caching just carries an extra rule on the preview deploy. If you already maintain a _headers file for asset caching, keeping the preview and production variants side by side avoids drift; the caching mechanics are covered in Cloudflare Pages Edge Caching Setup. One check worth adding to the gate suite: a smoke test that asserts the preview responds with X-Robots-Tag: noindex, so a refactor of the headers file can never quietly expose previews to crawlers.

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.
  • pull_request_target for fork previews: it runs untrusted code with your secrets. Split build (no secrets) from deploy (trusted context) and gate the deploy behind an environment approval.
Four properties of a preview worth having Four panels: ephemeral, isolated, findable and honest. Each names the setting that delivers it and the symptom when it is missing. Four properties of a preview worth having Ephemeral created on open, deleted on close a weekly sweep as backstop missing → hundreds of live copies Isolated scoped credentials, no prod access its own environment variables missing → a preview writes to production Findable posted as a comment + status check stable per-PR URL missing → reviewers read the diff Honest same build as production noindex, no analytics missing → it tests something else Build once and promote where you can, so the preview and the release are literally the same artifact.
The fourth is the subtle one: a preview built differently from production is a test of a thing you will never ship.

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.