Automating Preview Deploy Pipelines with GitHub Actions
Managed hosts give you previews for free, but sometimes you need to own the pipeline: you deploy to infrastructure without native previews, you want custom gates before a preview publishes, or you want one workflow that behaves identically across hosts. GitHub Actions can do all of it — build the site, deploy to a per-PR URL, comment the link on the pull request, and tear the environment down when the PR closes. This guide builds that pipeline end to end. It sits under Preview Environments for Pull Requests within Production-Ready Deployment & CI/CD Workflows.
Prerequisites
- An SSG repo (Astro, Eleventy, Hugo, or a Next.js static export) that builds with a single command.
- A deploy target you control: an object store/CDN, a static host with an API, or Cloudflare Pages/Wrangler.
- A token for the deploy target stored as a GitHub Actions secret.
- Familiarity with workflow triggers — this pipeline uses
pull_requestevents.
The Build-and-Deploy Workflow
This workflow fires on PR open, reopen, and every new push (synchronize). It builds the site, deploys the output to a per-PR location keyed on the PR number, and writes one sticky comment with the URL.
# .github/workflows/preview.yml
name: PR Preview
on:
pull_request:
types: [opened, reopened, synchronize]
permissions:
contents: read
pull-requests: write # needed to comment on the PR
concurrency:
group: preview-${{ github.event.number }}
cancel-in-progress: true
jobs:
preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run build # outputs to dist/
- name: Deploy preview
id: deploy
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
PR: ${{ github.event.number }}
run: |
npx wrangler pages deploy dist \
--project-name docs-preview \
--branch "pr-${PR}"
echo "url=https://pr-${PR}.docs-preview.pages.dev" >> "$GITHUB_OUTPUT"
- name: Comment the preview URL
uses: marocchino/sticky-pull-request-comment@v2
with:
header: preview
message: |
Preview for this PR is live: ${{ steps.deploy.outputs.url }}
Built from ${{ github.sha }}
Three details make this robust:
concurrencykeyed on the PR number withcancel-in-progress: true— if a reviewer pushes twice quickly, the older build is cancelled so the preview reflects the latest commit and you don't waste runner minutes.- The PR number (
github.event.number) is the identity of the environment. Every step that names the deploy uses it, so the URL (pr-42...) is stable across pushes — the same property you get from a managed host as described in Setting Up Deploy Previews on Netlify for Every Pull Request. - A sticky comment (single
header) updates one comment in place instead of spamming the PR with a new comment per push.
The example deploys with Wrangler, but the deploy step is the only host-specific part — swap it for an S3 sync, an rsync to a static host, or any CLI that publishes a directory to a per-PR path.
Caching to Keep Previews Fast
Previews rebuild on every push, so install and build time matter. Add dependency caching exactly as in Caching node_modules in GitHub Actions for Faster SSG Builds — the cache: npm line above already does the install half. For the generator's own output, add an actions/cache step keyed on content so unchanged pages aren't regenerated.
The Teardown Workflow
A preview that lingers after merge wastes storage and confuses reviewers. A second workflow fires on the closed event (which covers both merge and abandonment) and removes the per-PR environment:
# .github/workflows/preview-teardown.yml
name: PR Preview Teardown
on:
pull_request:
types: [closed]
permissions:
contents: read
pull-requests: write
jobs:
teardown:
runs-on: ubuntu-latest
steps:
- name: Remove preview
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
PR: ${{ github.event.number }}
run: |
# delete the per-PR deployment / directory on your target
./scripts/delete-preview.sh "pr-${PR}"
- name: Update comment
uses: marocchino/sticky-pull-request-comment@v2
with:
header: preview
message: 'Preview for pr-${{ github.event.number }} has been torn down.'
Because the teardown reuses the same header: preview, it overwrites the live-link comment with a "torn down" note rather than leaving a dead URL in the thread.
Measured Impact
On a 900-page Astro docs repo deploying previews to Cloudflare Pages via this pipeline, timings came from the Actions run summary (per-step durations) over ~30 PRs:
| Stage | Cold run | Warm cache |
|---|---|---|
npm ci | 44 s | 12 s |
npm run build | 71 s | 33 s |
Deploy (wrangler pages deploy) | 9 s | 9 s |
| Comment | 2 s | 2 s |
| Total preview turnaround | ~126 s | ~56 s |
With caching the median PR went from push to a live preview link in under a minute (~56 s). The concurrency cancellation removed an average of 1.3 redundant builds per PR (read from cancelled-run counts), and the teardown job kept active previews bounded at the number of open PRs instead of accumulating one per push.
Pitfalls & Rollback
pull_request_targetfor fork secrets: building forked PRs with secrets requires care —pull_request_targetruns with repo secrets but checks out the base by default, which can be a security hole if you then check out untrusted code. Prefer not deploying forks, or deploy without secrets.- Comment permission: the job needs
permissions: pull-requests: writeor the comment step fails silently. - No concurrency control: without the
concurrencyblock, rapid pushes race and the published preview may reflect an older commit. - Orphaned previews: if teardown is skipped (e.g., the workflow errors), previews accumulate. Add a scheduled cleanup that removes
pr-*deploys older than N days as a backstop. - Secrets in preview output: never bake production secrets into a publicly reachable preview build; scope a staging token to the preview pipeline.
- Rollback: previews are fully isolated from production — they deploy to
pr-*URLs only. To disable, delete the two workflow files; nothing about production deploys changes. Existing previews are removed by their teardown jobs or the scheduled backstop.
Conclusion
A self-owned preview pipeline is four moving parts: build, deploy to a PR-numbered URL, comment a sticky link, and tear down on close. GitHub Actions wires all four with two workflows sharing one identity — the pull request number. Add concurrency cancellation and dependency caching and you get a sub-minute turnaround that rivals a managed host while working against any target you control. When your host offers good native previews, use them; build this when you need the control. For the broader rationale and the gating story, see Preview Environments for Pull Requests.
FAQ
Why build my own preview pipeline instead of using a host's built-in previews?
Use built-in previews when your host offers them and they fit. Build your own in GitHub Actions when you self-host the preview target, need custom checks before publishing, deploy to a host without native previews, or want one pipeline that works the same across multiple hosts.
How do I give each pull request a unique preview URL?
Key the deploy on the pull request number, which GitHub exposes as the event number. Deploy into a per-PR path or subdomain like pr-42 so each open PR has its own isolated URL that stays stable across pushes.
How does the preview link get posted on the PR?
Have the workflow create or update a single sticky comment with the URL using the GitHub API or a comment action. Updating one comment instead of posting a new one each push keeps the PR readable.
How do I clean up a preview when the PR closes?
Run a second workflow triggered on the pull_request closed event that deletes the per-PR deploy directory or environment and updates the comment. Tearing down on close keeps storage and active previews bounded.
Related
- Parent: Preview Environments for Pull Requests — why per-PR previews matter and how to gate on them.
- Setting Up Deploy Previews on Netlify for Every Pull Request — the managed-host equivalent of this pipeline.
- Caching node_modules in GitHub Actions for Faster SSG Builds — what keeps preview rebuilds fast.
- Production-Ready Deployment & CI/CD Workflows — where previews fit the release lifecycle.