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" 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_targetworkflow 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.
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 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.
Related Jump to heading
- Environment and Deployment Branches β the parent topic.
- Branch-per-Environment GitOps Patterns β long-lived environments.
- Scoping Deploy Keys and Tokens β limiting preview credentials.
- Using Draft Pull Requests Effectively β skipping previews for early drafts.