“Works on my machine” has a more expensive enterprise version: “works in staging, but staging got changed by someone else in the meantime.” A shared staging environment, updated by several PRs at once, never really tests one PR in isolation. An ephemeral environment per pull request solves that exact problem: every PR gets its own full, disposable instance that exists only for the duration of the review.

Automatic provisioning, driven by the PR lifecycle

An ephemeral environment gets created when a PR opens and destroyed when it closes, with no manual step. The mechanism relies on a webhook triggering CI on the PR’s lifecycle events, not just on pushes:

# GitHub Actions: provision on open, destroy on close
on:
  pull_request:
    types: [opened, synchronize, reopened, closed]

jobs:
  deploy-preview:
    if: github.event.action != 'closed'
    steps:
      - run: |
          kubectl create namespace pr-${{ github.event.number }} \
            --dry-run=client -o yaml | kubectl apply -f -
          helm upgrade --install app-pr-${{ github.event.number }} ./chart \
            --namespace pr-${{ github.event.number }} \
            --set image.tag=${{ github.sha }}

  destroy-preview:
    if: github.event.action == 'closed'
    steps:
      - run: kubectl delete namespace pr-${{ github.event.number }} --ignore-not-found

The destroy job is the part most often neglected at design time, even though it carries most of the system’s economic value: an environment that provisions automatically but never reliably tears down accumulates an infrastructure cost that grows with every closed PR that wasn’t actually cleaned up.

The namespace alone isn’t isolation

A Kubernetes namespace isolates resources by name, not by network reach or resource consumption. Without additional constraints, a preview environment running a slightly too enthusiastic load test can starve a shared node’s resources away from other preview environments, or accidentally reach a service in another namespace for lack of a restrictive NetworkPolicy:

# ResourceQuota: cap what an ephemeral environment can consume
apiVersion: v1
kind: ResourceQuota
metadata:
  name: preview-quota
  namespace: pr-1234
spec:
  hard:
    requests.cpu: "2"
    requests.memory: 4Gi
    pods: "10"

A ResourceQuota per preview namespace, applied at provisioning time, keeps a runaway ephemeral environment from degrading the shared platform every other preview runs on.

External dependencies: what breaks most often

A service depending on a database, a message queue, or a third-party service raises the trickiest question about an ephemeral environment: share those dependencies across previews (risking data collisions between PRs) or provision one instance per PR (cost and startup time). The most common pragmatic answer: lightweight or stateless dependencies provisioned per PR (an ephemeral PostgreSQL instance with a small test dataset), and heavy or external dependencies (a paid third-party service, a large data cluster) mocked or pointed at a shared read-only instance.

The real cost, and why it stays hidden

The cost of an ephemeral environment isn’t limited to compute consumed during review: it includes startup time on every PR (which slows the feedback loop if provisioning takes several minutes), and the residual cost of environments whose destruction silently failed (a missed webhook, a cleanup job that timed out). A periodic audit that lists pr-* namespaces with no matching open PR catches what the close webhook missed:

# Orphaned preview namespaces: no matching open PR left
comm -23 \
  <(kubectl get ns -l type=preview -o name | sed 's#namespace/##' | sort) \
  <(gh pr list --state open --json number --jq '.[] | "pr-" + (.number|tostring)' | sort)

Without this safety net, a system meant to save resources compared to a permanent shared staging environment ends up quietly consuming more of them instead. Designing this automated provisioning properly, guardrails included, is part of the work of industrializing a CI/CD chain.

Takeaway

An ephemeral environment per pull request solves a real isolation problem between reviews, but shifts the cost to two places that need designing in from the start: resource constraints per preview namespace, and a cleanup mechanism that doesn’t depend solely on an always-reliable PR-close webhook. Without both safeguards, the isolation gain gets paid back in silent cost drift, exactly the problem the system was meant to solve on the shared-staging side.