Back to Blog
DevOps13 min readJun 2026

Ephemeral Preview Environments

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.

DevOpsEnvironmentsPreviewCI/CD
SB

Sri Balaji

Founder · TheSimplifiedTech

On this page

Reviewing a PR by reading code and hoping

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 core principle

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.

triggerdeployloadURLapproveon close
Open / push PR

GitHub

CI builds

build + push image

Ephemeral env provisioned

per-PR namespace

Reviewer tests

live preview URL

Merge / close PR

GitHub event

Auto-teardown

delete namespace

Seed data

fixtures / clone

One pull request's environment, from open to auto-teardown

  1. 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. 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. 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. 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. 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. 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.

DimensionShared stagingEphemeral preview
IsolationOne env, many PRs collideOne env per PR, fully isolated
Feedback speedWait your turn / merge to see itLive URL while the PR is open
Blast radiusA bad branch breaks it for everyoneBreakage stays inside that PR
Cost shapeAlways-on, fixed costPay only while PRs are open
DataDrifts, gets polluted over timeFresh, seeded, deterministic
TeardownNever, it just rotsAutomatic 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: true

env:
  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.

.github/workflows/ttl-sweep.yml
yaml
name: Preview TTL Sweeper

on:
  schedule:
    - cron: "0 * * * *"   # hourly backstop

jobs:
  sweep:
    runs-on: ubuntu-latest
    steps:
      - name: Configure cluster access
        run: echo "${{ secrets.KUBECONFIG }}" > "$HOME/.kube/config"

      - name: Delete preview namespaces older than 72h
        run: |
          cutoff=$(date -d '72 hours ago' +%s)
          kubectl get ns -o json \
            | jq -r '.items[]
                | select(.metadata.name | startswith("pr-"))
                | [.metadata.name, .metadata.creationTimestamp]
                | @tsv' \
            | while IFS=$'\t' read -r ns created; do
                age=$(date -d "$created" +%s)
                if [ "$age" -lt "$cutoff" ]; then
                  echo "Sweeping stale namespace $ns"
                  kubectl delete namespace "$ns" --ignore-not-found
                fi
              done

Teardown is a feature, not an afterthought

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)

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

Want to go deeper?

This article covers concepts taught hands-on in the Cloud Engineer and DevOps career paths, with real terminal labs, production scenarios, and structured lessons.