Preview environments per pull request Jump to heading

A reviewer reading a diff of a UI change has to imagine the result. A preview environment removes the imagining: each pull request is deployed to its own short-lived environment with a unique URL, linked from the pull request, rebuilt on every push and destroyed when the pull request closes. Designers, product owners and testers can check the change before it merges, without checking out the branch. The practicalities decide whether previews help or become a cost and security problem: deterministic naming from the pull request number, keeping secrets away from untrusted fork code, linking the URL where reviewers will see it, and β€” most often forgotten β€” reliable teardown. This page covers each, within environment and deployment branches.

When to use this approach Jump to heading

  • Reviewers need to see a running change, not just its diff β€” UI, documentation sites, APIs with examples.
  • Non-developers take part in review.
  • Shared staging is a bottleneck because several branches compete for it.
  • Your application can be deployed quickly and cheaply as an isolated instance.

Step 1 β€” Name environments from the pull request Jump to heading

Derive the environment name and URL from the pull request number, not the branch name. Numbers are unique, short and safe in hostnames; branch names can be long and contain characters that are invalid in DNS.

pr=1234
env="pr-$pr"
url="https://$env.preview.example.com"
echo "$env $url"
A preview environment's lifecycleA pull request is opened and a workflow deploys it to an environment named after its number. Each push redeploys the same environment. The URL is posted on the pull request. When the pull request is merged or closed, a workflow destroys the environment and its resources.PR opened#1234Deploypr-1234Link postedon the PRPush β†’ redeploysame nameClosed β†’ destroyall resourcesteardown is part of the design, not a clean-up task

Step 2 β€” Deploy on pull request events Jump to heading

Deploy on open, synchronise and reopen, using a concurrency group per pull request so a new push cancels an in-progress deploy of an older commit.

on:
  pull_request:
    types: [opened, synchronize, reopened]
concurrency:
  group: preview-${{ github.event.pull_request.number }}
  cancel-in-progress: true
jobs:
  deploy:
    if: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
    runs-on: ubuntu-latest
    environment:
      name: pr-${{ github.event.pull_request.number }}
      url: https://pr-${{ github.event.pull_request.number }}.preview.example.com
    steps:
      - uses: actions/checkout@v4
      - run: ./deploy/preview.sh "pr-${{ github.event.pull_request.number }}"

Setting environment.url makes the forge show a β€œView deployment” link on the pull request. Concurrency groups are covered in cancelling superseded CI runs with concurrency groups.

Step 3 β€” Keep fork code away from secrets Jump to heading

Deploying needs credentials, and a pull request from a fork contains code you have not reviewed. The if condition above skips forks entirely. If you need previews for fork pull requests, have a maintainer trigger them after reviewing the change, never automatically with secrets in scope.

# Maintainer-triggered preview for a reviewed fork PR, via a manual workflow
gh workflow run preview.yml -f pr=1234 -f sha="$(gh pr view 1234 --json headRefOid --jq .headRefOid)"

⚠️ SAFETY WARNING: Do not deploy fork pull requests from a pull_request_target workflow that checks out the fork’s code with secrets available. That runs untrusted code with your deploy credentials. See running CI for fork pull requests safely.

Should this pull request get a preview automatically?A pull request from a branch in the same repository, opened by someone with write access, can deploy automatically. A pull request from a fork waits until a maintainer reviews it and triggers the preview by commit ID. A pull request that only changes files that do not affect the running app can skip the preview.Where does the pull request come from?same repositoryDeploy automaticallycredentials in scopeforkMaintainer triggersafter review, pinned SHAdocs / CI onlySkip previewpath filterpin the reviewed commit ID β€” a later push should not inherit the approval

Step 4 β€” Use isolated, disposable data Jump to heading

Previews must not point at production data. Use a seeded database per preview, or a shared preview database with per-environment schemas, and treat all preview data as disposable.

# deploy/preview.sh (excerpt)
name=$1
createdb --if-not-exists "preview_$name" 2>/dev/null || createdb "preview_$name"
psql "preview_$name" -f db/seed/preview.sql

Step 5 β€” Tear down on close Jump to heading

Destroy the environment when the pull request is closed, whether merged or not. Without this, previews accumulate cost silently.

on:
  pull_request:
    types: [closed]
jobs:
  teardown:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: ./deploy/destroy-preview.sh "pr-${{ github.event.pull_request.number }}"
      - env: { GH_TOKEN: "${{ github.token }}" }
        run: gh api -X DELETE "repos/${{ github.repository }}/environments/pr-${{ github.event.pull_request.number }}" || true

Step 6 β€” Sweep for leftovers Jump to heading

Teardown jobs fail sometimes β€” a cloud API timeout, a cancelled run. A nightly sweep compares running previews with open pull requests and destroys any without one.

open=$(gh pr list --state open --limit 500 --json number --jq '.[].number' | sed 's/^/pr-/' | sort)
running=$(./deploy/list-previews.sh | sort)
printf '%s\n' "$open" > "$TMPDIR/open.txt"; printf '%s\n' "$running" > "$TMPDIR/running.txt"
comm -13 "$TMPDIR/open.txt" "$TMPDIR/running.txt" | while read -r env; do ./deploy/destroy-preview.sh "$env"; done
Running previews with and without a sweepAn illustrative month. Without a nightly sweep, failed teardowns leave orphaned previews that accumulate week by week. With the sweep, the number of running previews tracks the number of open pull requests.running previews at end of week (illustrative)week 1, no sweep24week 4, no sweep61week 1, sweep21week 4, sweep22a few failed teardowns a week add up quickly

Step 7 β€” Keep previews fast and cheap Jump to heading

Previews are only useful if they are ready when reviewers look. Reuse build caches, deploy only the services that changed, and scale preview instances to zero when idle if your platform supports it.

./deploy/preview.sh "pr-$pr" --only "$(git diff --name-only origin/main...HEAD | cut -d/ -f1-2 | sort -u | tr '\n' ',')"

Changed-path detection is described in detecting affected projects from git diff.

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Do previews replace staging? Jump to heading

Not entirely. Previews test one change in isolation; staging tests changes together with production-like data and integrations. Previews reduce how much has to be caught on staging.

What about services that cannot be duplicated cheaply? Jump to heading

Share them across previews β€” one preview message queue or search cluster with per-environment prefixes β€” and duplicate only the services the change affects.

How do reviewers find the preview? Jump to heading

Through the deployment link the forge shows on the pull request, plus a comment with the URL for people reading notifications by email.