all posts

Wiring preview environments into pull requests

Ajay Kumar··8 min read

Code review has an inherent limit: you're reading a description of behaviour rather than observing it. A reviewer can tell you the query looks wrong, but not that the empty state renders with a broken image, or that the new form is unusable on a phone.

A URL on the pull request fixes that, and it also lets designers, product people and support staff look at a change before it exists. The wiring is straightforward; the decisions around it are what determine whether the thing stays useful after month two.

The workflow

name: preview
on:
  pull_request:
    types: [opened, synchronize, reopened, closed]

# One deploy at a time per PR; a new push cancels the in-flight one
concurrency:
  group: preview-${{ github.event.pull_request.number }}
  cancel-in-progress: true

jobs:
  deploy:
    if: github.event.action != 'closed'
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write     # to comment the URL back
    steps:
      - uses: actions/checkout@v4

      - name: Deploy preview
        id: deploy
        env:
          PANDASTACK_API_KEY: ${{ secrets.PANDASTACK_API_KEY }}
        run: |
          APP="pr-${{ github.event.pull_request.number }}"
          # Idempotent: create on first run, redeploy on later pushes
          pandastack apps create --name "$APP" \
            --git-url "${{ github.server_url }}/${{ github.repository }}" \
            --git-ref "${{ github.head_ref }}" 2>/dev/null || true
          pandastack apps deploy "$APP" --commit "${{ github.sha }}" --wait
          echo "url=$(pandastack apps get "$APP" --json | jq -r .url)" >> $GITHUB_OUTPUT

      - uses: marocchino/sticky-pull-request-comment@v2
        with:
          header: preview
          message: |
            Preview: ${{ steps.deploy.outputs.url }}
            Commit: `${{ github.sha }}`

Two details in there that matter more than they look. The `concurrency` block with `cancel-in-progress` means pushing three commits in a minute doesn't queue three deploys — you get one, for the newest commit. And the sticky comment updates in place rather than adding a new comment per push, which is the difference between a useful PR thread and forty deploy notifications.

The database decision

This is the actual design question. A preview without a database is a preview of your CSS. Four options, with genuinely different consequences.

A shared preview database

Simplest, and fine for a small team where previews are read-mostly. It breaks the first time two PRs have conflicting migrations, and it breaks badly — one PR's migration runs, the other PR's code doesn't match the schema, and the failure looks like the second PR is broken when it isn't.

A fresh database seeded from fixtures

Clean, isolated, and reproducible. The problem is that a preview with twelve rows doesn't let anyone evaluate the change — the list view someone needs to look at is empty, and the performance question is unanswerable.

A clone of a prepared database

The version I'd recommend. Maintain one anonymised, production-shaped database, and give each PR a copy-on-write clone of it. Isolated, realistic, and fast enough that it's a step in the deploy rather than a separate project.

# Clone the prepared base; the PR's migrations run against its own copy
DB=$(pandastack db clone db_preview_base --label "pr-${PR}" --json | jq -r .id)
URL=$(pandastack db get "$DB" --json | jq -r .connection_url)
pandastack apps env set "pr-${PR}" DATABASE_URL "$URL"

# Destructive migrations, seed scripts, anything — it's a private copy

Pointing at production

Don't. It happens, usually as an expedient that became permanent, and it means unreviewed code from any branch has write access to customer data. The first incident is memorable.

Cleanup is the part that gets skipped

Everyone builds the create path. The delete path is an afterthought, and six months later there are two hundred environments belonging to merged PRs, quietly costing money.

  teardown:
    if: github.event.action == 'closed'
    runs-on: ubuntu-latest
    steps:
      - env:
          PANDASTACK_API_KEY: ${{ secrets.PANDASTACK_API_KEY }}
        run: |
          APP="pr-${{ github.event.pull_request.number }}"
          pandastack apps delete "$APP" --yes || true
          pandastack db delete --label "$APP" --yes || true

The `closed` event covers both merge and close, so that one job handles both endings. But it will miss things — a workflow that failed, a PR closed while CI was down, a repository renamed. Add a scheduled sweep that deletes preview environments older than a couple of weeks, because the belt-and-braces version is about ten lines and the alternative is a bill nobody can explain.

If your platform sleeps idle apps, the cost pressure here drops substantially — a forgotten preview that nobody has opened in a month isn't consuming compute. It's still worth deleting for tidiness, but it stops being an urgent financial problem.

Pull requests from forks

The security wrinkle, and it's easy to get wrong. GitHub deliberately does not expose repository secrets to workflows triggered by `pull_request` from a fork — because that workflow runs code from the fork, and a malicious PR could otherwise print your deploy credentials.

The tempting fix is `pull_request_target`, which does have secrets. Be careful: it checks out the base branch by default but runs in a privileged context, and checking out the PR's code under it hands an attacker your credentials. If you use it, do not check out or execute PR code in that job.

For open-source repositories the safe pattern is a manual gate: previews deploy automatically for branches in the repository, and require a maintainer to apply a label for fork PRs. Slightly less convenient, considerably better than leaking a deploy token.

Making them worth having

The failure mode for preview environments isn't technical — it's that they get built, used enthusiastically for a month, and then quietly ignored. What keeps them alive:

  • Speed. If the preview takes eight minutes, reviewers won't wait for it. Under two is where people actually click the link.
  • The comment must update in place with the current commit. A stale URL that serves old code once will make people distrust every preview afterwards.
  • Realistic data, or the preview only answers cosmetic questions.
  • A visible failure state. If the deploy fails, the comment should say so with a link to logs — silence reads as 'not ready yet' and wastes the reviewer's time.
  • Basic access control. A preview URL is unlisted, not private; if it holds anything resembling real data, it needs authentication.

Get those right and the reviews change character. People stop asking 'does this look right?' and start saying 'I clicked through it and the empty state is broken' — which is a much better class of review comment, and it arrives before the code ships rather than after.

Frequently asked questions

How do I create a preview environment for every pull request?

A GitHub Actions workflow on pull_request events that deploys the branch to a per-PR app and comments the URL back on the pull request. Two details matter more than they look: a concurrency group keyed on the PR number with cancel-in-progress, so three quick pushes produce one deploy rather than three queued ones, and a sticky comment that updates in place rather than adding a new comment per push. Without the second, an active PR accumulates dozens of deploy notifications and people stop reading the thread.

Should preview environments share a database?

Only for small teams with read-mostly previews. A shared preview database breaks the first time two pull requests carry conflicting migrations, and it breaks confusingly: one PR's migration applies, the other PR's code no longer matches the schema, and the failure looks like the second PR is broken when it is not. The better pattern is one prepared, anonymised, production-shaped database that each PR clones copy-on-write. That gives isolation and realistic data, and a clone is fast enough to be a step in the deploy rather than a project.

How do I clean up preview environments?

Handle the pull_request closed event, which fires for both merges and closes, and delete the app and its database there. Then add a scheduled sweep that removes preview environments older than a week or two, because the event-driven path will miss cases — a failed workflow, a PR closed while CI was down, a renamed repository. Everyone builds the create path and treats teardown as an afterthought, which is how you end up with two hundred environments belonging to merged pull requests.

Why don't preview deploys work for pull requests from forks?

GitHub deliberately withholds repository secrets from pull_request workflows triggered by forks, because that workflow executes code from the fork and would otherwise let a malicious pull request print your deploy credentials. The tempting workaround, pull_request_target, does receive secrets but runs in a privileged context — checking out and executing the PR's code under it hands an attacker those credentials. For public repositories, the safe pattern is automatic previews for in-repo branches and a maintainer-applied label to authorise fork previews.

What makes teams stop using preview environments?

Almost never the technology. They get built, used enthusiastically for a month, and quietly abandoned. The causes are consistent: deploys slow enough that reviewers will not wait, so under two minutes is the target; stale comments serving old code, which destroys trust in every future preview; data too thin to evaluate anything beyond CSS; and silent failures, where a broken deploy looks identical to one still in progress. Fix those four and previews change the character of code review rather than becoming another unused link.

Keep reading

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.