Spin up a full, throwaway environment for every pull request. See why per-PR previews speed feedback and de-risk merges, and how to build them with namespaces, vcluster, data seeding, cost control, and automatic teardown.
You open a pull request. There are 400 lines across nine files, a migration, a new API endpoint, and a tweak to the checkout button. You read the diff carefully, you leave a few comments about naming, you check the tests are green, and then you click Approve, because the diff *looks* right. But you never actually saw the checkout button. You never clicked it. You're approving a description of a change, not the change itself.
This is the quiet gap in most review workflows. The diff tells you what the code says it does. It does not tell you whether the page renders, whether the migration runs cleanly, whether the new endpoint returns what the frontend expects, or whether that innocent CSS change broke the layout on mobile. To know those things, someone has to *run* the change, and "someone" usually means merging it to shared staging and finding out later, in front of everyone.
Ephemeral preview environments close that gap. Every pull request gets its own complete, isolated, live environment, a real URL you can click, that exists for exactly as long as the PR is open and disappears the moment it's merged or closed.
Who this is for
Engineers and platform/DevOps folks who already have CI and a deploy target (Kubernetes or a PaaS) and want reviewers to test the running change, not just read the diff. Comfort with **GitHub Actions**, **kubectl**, and basic Kubernetes namespaces is assumed. If those are new, start with the labs linked at the end.
Review the running change, not just the diff
Review the running change, not just the diff. A diff shows intent; a preview environment shows behavior, and behavior is what ships.
The whole idea collapses into one move: give every PR a place to *be alive*. Not a description, not a screenshot the author chose, not staging next week, a running instance of the exact code in that branch, with its own database and its own URL, that anyone can poke at.
Test-driving the actual car before buyingClicking through the running PR before approving
A fitting room, try it on, then put it backSpin up the env, test, auto-teardown on close
A sandbox that gets raked flat each eveningFresh seeded data per environment, destroyed on merge
Reading a recipe vs. tasting the dishReading the diff vs. exercising the feature
Why a throwaway environment per PR feels natural once you've used it
How the flow works end to end
The lifecycle is fully event-driven off the pull request. Opening or pushing to a PR provisions (or updates) its environment; closing or merging the PR destroys it. Nothing is manual, which is the only way this scales past a handful of PRs.
One pull request's environment, from open to auto-teardown
1
PR opened or updated
A developer opens a pull request or pushes a new commit. GitHub fires a `pull_request` event that kicks off the workflow.
2
CI builds the artifact
The pipeline builds the app, runs unit tests, and pushes a container image tagged with the PR number or commit SHA so the deploy is reproducible.
3
Environment provisioned
A dedicated namespace (or vcluster) is created for `pr-<number>`, and the image is deployed there with its own config, database, and ingress hostname.
4
Data seeded
The fresh environment is loaded with deterministic fixtures (or a sanitized clone) so the feature has realistic data to render and test against.
5
Reviewer tests the live URL
A bot comments the preview URL on the PR. Reviewers, designers, and PMs open it and exercise the actual feature, not the diff.
6
Merge or close triggers teardown
Closing or merging the PR fires a `closed` event; the workflow deletes the namespace and everything in it. Cost stops the instant review ends.
Shared staging vs. ephemeral previews
Most teams already have a shared staging environment, and previews aren't meant to kill it, staging is still useful as a stable, integrated, production-like sanity check before release. But for *review*, a single shared environment is the wrong shape: every open PR fights over it.
Dimension
Shared staging
Ephemeral preview
Isolation
One env, many PRs collide
One env per PR, fully isolated
Feedback speed
Wait your turn / merge to see it
Live URL while the PR is open
Blast radius
A bad branch breaks it for everyone
Breakage stays inside that PR
Cost shape
Always-on, fixed cost
Pay only while PRs are open
Data
Drifts, gets polluted over time
Fresh, seeded, deterministic
Teardown
Never, it just rots
Automatic on merge/close
What changes when each PR gets its own environment
The trade is real: previews add infrastructure complexity and require discipline around teardown and cost. But the payoff, parallel, isolated, click-to-test review, is what de-risks merges. For the broader picture of how preview fits alongside dev/staging/prod, see Environments & Config: Dev / Staging / Prod Done Right.
Build it: a per-PR preview workflow
Here's a complete GitHub Actions workflow. One job deploys a preview on every push to an open PR; a separate job tears it down when the PR closes. The key is the if: conditions on github.event.action so the same workflow handles both halves of the lifecycle. It assumes a Kubernetes cluster and a Helm chart, but the pattern maps to any deploy target.
.github/workflows/preview.yml
yaml
name: PR Preview Environment
on:
pull_request:
types: [opened, synchronize, reopened, closed]
# One env per PR; new pushes cancel the in-flight deploy.concurrency:
group: preview-${{ github.event.pull_request.number }}
cancel-in-progress: trueenv:
PR: ${{ github.event.pull_request.number }}
NS: pr-${{ github.event.pull_request.number }}
IMAGE: ghcr.io/${{ github.repository }}/app
jobs:
deploy:
# Run on open / push / reopen, but NOT on close.if: github.event.action != 'closed'runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build and push image
run: |
echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io \
-u ${{ github.actor }} --password-stdin
docker build -t "$IMAGE:$GITHUB_SHA" .
docker push "$IMAGE:$GITHUB_SHA"
- name: Configure cluster access
run: echo "${{ secrets.KUBECONFIG }}" > "$HOME/.kube/config"
- name: Provision namespace + deploy
run: |
kubectl create namespace "$NS" --dry-run=client -o yaml \
| kubectl apply -f -
helm upgrade --install "$NS" ./chart \
--namespace "$NS" \
--set image.tag="$GITHUB_SHA" \
--set ingress.host="$NS.preview.example.com" \
--wait --timeout 5m
- name: Seed deterministic test data
run: |
kubectl exec -n "$NS" deploy/app -- \
npm run seed:preview
- name: Comment preview URL on the PR
uses: actions/github-script@v7
with:
script: |
const url = `https://pr-${process.env.PR}.preview.example.com`;
github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: Number(process.env.PR),
body: `Preview environment is live: ${url}`,
});
teardown:
# Run ONLY when the PR is closed (merged or not).if: github.event.action == 'closed'runs-on: ubuntu-latest
steps:
- name: Configure cluster access
run: echo "${{ secrets.KUBECONFIG }}" > "$HOME/.kube/config"
- name: Delete the namespace
run: kubectl delete namespace "$NS" --ignore-not-found
vcluster when namespaces aren't enough
Namespaces share the host cluster's CRDs, controllers, and cluster-scoped resources. If a PR needs to install its own operators, webhooks, or a different Kubernetes version, run a **vcluster** (a virtual cluster inside a namespace) instead. You get cluster-level isolation per PR, still torn down by deleting one namespace. Practice the moving parts in the [kubectl lab](/labs/kubectl) and the [CI/CD lab](/labs/cicd).
Data seeding, cost, and teardown
Seeding: give the feature something to render
An empty environment is almost useless for review, most bugs only show up with realistic data. You have three options, in increasing cost and fidelity. Fixtures are a small, version-controlled set of records loaded by a seed script; fast, deterministic, and the right default. Sanitized clones copy a slice of production with PII scrubbed; higher fidelity but slower and a compliance burden. Snapshot restore boots the database from a pre-baked snapshot volume; fast and realistic but needs maintenance.
Whatever you pick, the data must be deterministic and per-environment. Two preview environments sharing one database is the single fastest way to make previews lie to you, one PR's writes corrupt another's reads. Each PR gets its own database (a per-namespace instance, or at minimum a per-PR schema).
Cost: the part that bites quietly
Ephemeral environments are cheap *if they're actually ephemeral*. The danger is forgotten environments that outlive their PR and quietly bill you. Three controls keep the bill honest: tie teardown to the PR closed event (already in the workflow above); add a TTL sweeper, a scheduled job that deletes any pr-* namespace older than, say, 72 hours regardless of PR state, as a backstop; and scale to zero during off-hours with a tool like KEDA so idle previews cost nothing.
Treat teardown with the same care as deploy. Build it first, test that closing a PR really deletes the namespace, and add the TTL sweeper as a safety net. A preview system without reliable teardown isn't a preview system, it's a leak.
Common mistakes that cost hours (or dollars)
No teardown = cost blowup. Environments tied only to the deploy step, with nothing watching the closed event, pile up forever. You discover it when finance asks why the cluster doubled. Always wire teardown to PR close *and* run a TTL sweeper as a backstop.
Sharing data between previews. One shared database across all PRs means one PR's migration or writes break every other preview. Each environment needs its own isolated, freshly seeded data store.
Slow provisioning kills adoption. If a preview takes 15 minutes to appear, reviewers stop waiting and go back to reading diffs. Cache image layers, use --wait with sane timeouts, seed minimal fixtures, and aim for a live URL in under a few minutes.
Leaking secrets into previews. Previews are more numerous and shorter-lived, so it's tempting to be sloppy with credentials. Use scoped, short-lived secrets and never point a preview at production data stores.
No concurrency control. Without a concurrency group, two quick pushes race to deploy the same namespace and leave it in a half-updated state. Cancel in-flight deploys per PR.
Takeaways
The whole article in seven lines
Reviewing a diff shows intent; a preview environment shows behavior, and behavior is what ships.
Give every PR its own complete, isolated, live environment with a real URL.
Drive the whole lifecycle off PR events: open/push provisions, close/merge tears down.
Use per-PR namespaces; reach for vcluster when a PR needs cluster-scoped isolation.
Seed deterministic, per-environment data, never share one database across previews.
Teardown is a first-class feature: tie it to PR close and add a TTL sweeper backstop.
Control cost with auto-teardown, TTL sweeps, and scale-to-zero on idle.
Where to go next
Previews sit on top of two foundations: a solid CI pipeline and a clear environment strategy. Shore those up, then practice the deploy and teardown mechanics hands-on.
This article covers concepts taught hands-on in the Cloud Engineer and DevOps career paths, with real terminal labs, production scenarios, and structured lessons.