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: two workflows, one PR number The pull_request opened or synchronize event triggers the top path: build the SSG, deploy to a per-PR URL such as pr-42, then create or update one sticky comment with the link. The pull_request closed event triggers the bottom path: a teardown job deletes the pr-42 deployment and overwrites the same sticky comment with a torn-down note. Both workflows key on github.event.number, so they act on the same environment. Open or update builds and publishes; close tears down on: pull_request [opened, reopened, synchronize] PR opened or pushed to Build SSG npm run build Deploy pr-42 unique URL Sticky comment post / update link on: pull_request [closed] PR closed merged or not Teardown delete pr-42 overwrites same comment Both workflows key on the same identity: github.event.number (the PR number)
Two workflows share one PR number: the open/update path builds and publishes; the close path removes the environment and overwrites the link comment.

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 same incremental-build cache strategy that speeds production builds applies unchanged to preview builds.

      - name: Cache build output
        uses: actions/cache@v4
        with:
          path: |
            .cache
            node_modules/.astro
          key: build-${{ runner.os }}-${{ hashFiles('src/**', 'content/**', 'package-lock.json') }}
          restore-keys: |
            build-${{ runner.os }}-

Place this step before npm run build. The restore-keys fallback means even a partial cache hit (a prior PR's build) seeds the incremental cache instead of starting cold. If your PRs are noisy — many small pushes — a shared cache across runners matters more than a per-branch one; see Sharing Build Cache Across CI Runners.

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.
The three workflows a preview needs Three workflows: one that builds and deploys a preview on each push to a pull request, one that comments the URL, and one that tears the preview down when the pull request closes. The three workflows a preview needs On push build + deploy preview On deploy comment the URL On close tear the preview down All three are short; keeping them in one file makes the lifecycle readable at a glance.
The third is the one most often missing, which is how a repository accumulates hundreds of live deployments.

Keep the Preview Loop Under Two Minutes

A preview that arrives after the reviewer has moved on is not part of the review. Three things keep it fast enough to matter.

Cache aggressively, because a preview build is the most frequent build you run — dependencies and the framework's own asset cache both belong in the workflow. Skip work that only production needs: sitemap generation, full-site search indexing and image re-encoding of unchanged assets can all be conditional. And run the content gates in parallel with the deploy rather than before it, so a slow link check does not delay the URL appearing.

Measure it like any other pipeline stage. If the median time from push to preview URL creeps past a couple of minutes, reviewers quietly fall back to reading diffs, and the mechanical review comments come back with them.

Where the two minutes go in a preview build Ranked bars: dependency install on a cold runner takes 52 seconds, the build takes 38, deployment takes 14 and commenting the URL takes 3. With caches restored, install drops to 9 seconds and the whole loop fits in about a minute. Where the two minutes go in a preview build Install (cold) npm ci from the registry 52 s Build generator + asset pipeline 38 s Deploy upload + promote preview 14 s Comment URL API call 3 s Shared linear scale · medium documentation site, GitHub Actions ubuntu-latest, cold runner
The first bar is the one caching removes; the second is the one the framework build cache halves.

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.