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