A green CI run from part five proves the code compiles, type-checks, and passes its tests. It does not prove the change looks right, feels right, or behaves correctly against the interactions a test suite did not anticipate. A reviewer reading a diff is reconstructing a running application in their head. A preview environment replaces that reconstruction with the real thing.
Why passing checks are not the same as reviewing the change
Unit and integration tests validate what the author thought to test. They do not validate a layout regression on a narrow viewport, a confusing empty state, a broken redirect after form submission, or a query parameter that behaves unexpectedly when combined with another feature merged last week. None of that is a CI failure. All of it is a real defect a reviewer would catch in thirty seconds with a working link.
This is also where non-technical stakeholders become useful reviewers. A product owner cannot review a TypeScript diff, but they can absolutely tell you a preview link does not match what was agreed. Preview environments extend the reviewing audience beyond the engineering team without giving anyone direct access to staging or production.
What a preview environment needs to be useful
A preview environment earns its cost only if it clears a few bars:
- It deploys automatically, without a manual step the author has to remember.
- It is reachable by URL in the pull request itself, not buried in a build log.
- It uses safe, disposable data, never a copy of production.
- It updates on every push to the branch, so the link stays current through review iterations.
- It tears itself down when the pull request closes, so previews do not silently accumulate cost or become forgotten attack surface.
A preview that requires the author to run a manual deploy script defeats the purpose—it will be skipped exactly when time is short, which is precisely when a fast preview matters most.
Provisioning a preview per pull request
For a project already deploying to Fly.io in later parts of this series, the same platform can host an ephemeral app per pull request, named from the PR number so it never collides with another branch's preview.
# .github/workflows/preview.yml name: Preview Deploy on: pull_request: types: [opened, synchronize, reopened, closed] concurrency: group: preview-${{ github.event.pull_request.number }} cancel-in-progress: true jobs: deploy-preview: if: github.event.action != 'closed' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: superfly/flyctl-actions/setup-flyctl@master - name: Deploy ephemeral app env: FLY_API_TOKEN: ${{ secrets.FLY_PREVIEW_TOKEN }} run: | flyctl apps create "pr-${{ github.event.pull_request.number }}-web" --org my-org || true flyctl deploy \ --app "pr-${{ github.event.pull_request.number }}-web" \ --config fly.preview.toml \ --build-arg NODE_ENV=production \ --strategy immediate - name: Comment preview link uses: actions/github-script@v7 with: script: | const url = `https://pr-${context.payload.pull_request.number}-web.fly.dev`; await github.rest.issues.createComment({ ...context.repo, issue_number: context.payload.pull_request.number, body: `Preview deployed: ${url}`, }); destroy-preview: if: github.event.action == 'closed' runs-on: ubuntu-latest steps: - uses: superfly/flyctl-actions/setup-flyctl@master - env: FLY_API_TOKEN: ${{ secrets.FLY_PREVIEW_TOKEN }} run: flyctl apps destroy "pr-${{ github.event.pull_request.number }}-web" --yes
Two design details matter beyond the mechanics. The concurrency group is scoped to the pull request number, so a rapid sequence of pushes does not race two deploys against each other. And a separate FLY_PREVIEW_TOKEN, scoped narrowly, keeps preview deployment permissions distinct from whatever token later deploys to staging or production—part eight in this series covers that separation in more depth.
Safe test data instead of production data
A preview database should never be a clone of production. Beyond the obvious privacy exposure, a full production copy makes every preview slow to provision and expensive to keep alive. A seed script that creates a small, representative dataset—a handful of users, sample records covering the interesting edge cases—is both safer and faster to reset. Where a bug specifically requires production-shaped data to reproduce, prefer a sanitized export with identifying fields scrubbed, not a raw copy.
| Data source for previews | Privacy risk | Setup cost | Realism |
|---|---|---|---|
| Full production copy | High | High | High |
| Sanitized/anonymized export | Low | Medium | Medium-high |
| Scripted seed data | None | Low | Depends on seed quality |
For most feature review, scripted seed data is the right default. Reach for a sanitized export only when reproducing a specific, hard-to-fabricate production condition.
Previews catch workflow defects, not just code defects
A subtle benefit of preview environments is that they exercise the deployment path itself on every pull request, not just the application code. A migration that fails against a fresh database, a missing environment variable, a build argument nobody remembered to update—these surface as a broken preview well before they would otherwise surface during an actual staging or production deploy. In effect, every pull request becomes a low-stakes rehearsal of the release process described in part nine.
Common mistakes
- Pointing the preview database at a shared instance, so two open pull requests corrupt each other's data.
- Forgetting the teardown job, leaving dozens of forgotten preview apps running and billing quietly in the background.
- Using the same deploy token for previews and production, so a compromised preview credential has production-level blast radius.
- Skipping migrations in the preview deploy, which hides exactly the failure mode a preview is meant to catch.
- Treating a working preview as equivalent to a passing production deploy—previews validate the deployment path, not production's scale, secrets, or traffic patterns.
Practical checklist
- Every pull request deploys an isolated preview automatically, with no manual step.
- The preview URL is posted where reviewers will actually see it.
- Preview deployments use scripted or sanitized data, never a raw production copy.
- Preview deploy credentials are scoped separately from staging and production credentials.
- Preview apps and their databases are destroyed automatically when the pull request closes.
- Migrations run as part of the preview deploy, not skipped for speed.
Previous: Your First GitHub Actions Pipeline: Lint, Typecheck, Test, Build
Next: Database Migrations Without Panic: Local → Preview → Staging → Production. Previews already run migrations on every pull request; the next part turns that habit into a full, backward-compatible migration discipline.

