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_request events.
Ephemeral per-PR preview pipeline sequence A pull request opened or updated triggers build, deploy to a per-PR URL, and a sticky comment with the link. A pull request closed event triggers a teardown job that deletes the preview and updates the comment. Open or update builds and publishes; close tears down PR opened / synchronize Build SSG npm run build Deploy pr-42 unique URL Sticky comment post / update link PR closed merged or not Teardown delete pr-42
Two workflows share one PR number: the open/update path builds and publishes; the close path removes the environment.

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:

  • concurrency keyed on the PR number with cancel-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:

StageCold runWarm cache
npm ci44 s12 s
npm run build71 s33 s
Deploy (wrangler pages deploy)9 s9 s
Comment2 s2 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_target for fork secrets: building forked PRs with secrets requires care — pull_request_target runs 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: write or the comment step fails silently.
  • No concurrency control: without the concurrency block, 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.

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.

Static Site Generators in Production