InsightsPlatform Engineering
Ephemeral preview environments for every pull request
How to give each pull request its own short-lived environment on Kubernetes, with a namespace, a URL, seeded data and automatic teardown, without letting the bill grow with every open branch.
Published February 4, 20255 min read
A shared staging environment becomes a queue. One team deploys a branch to test it, another overwrites it ten minutes later, and a reviewer approving a pull request is looking at code that may not be what staging is running. Product managers and designers, who would happily check a change before it merges, cannot do so without asking an engineer to deploy it.
Preview environments fix this by giving every pull request its own running copy of the service, reachable at a predictable URL, created when the pull request opens and destroyed when it closes. The idea is simple. The work is in making them cheap, fast and disposable enough that nobody thinks twice about having fifty of them.
One namespace per pull request
On Kubernetes, the natural unit is a namespace named after the repository and pull request number, such as orders-pr-482. Everything the preview needs lives inside it, so deleting the namespace deletes the environment.
Each preview namespace should get the same guardrails a team namespace gets, applied automatically:
- A ResourceQuota, so one preview cannot consume a node by itself.
- A LimitRange with small defaults, since previews do not need production sizing.
- NetworkPolicy that blocks traffic to production namespaces and databases.
- Labels for the repository, the pull request number and the owning team, which drive cleanup and cost reporting.
apiVersion: v1
kind: Namespace
metadata:
name: orders-pr-482
labels:
preview.acme.dev/repo: orders
preview.acme.dev/pr: "482"
team: checkout
---
apiVersion: v1
kind: ResourceQuota
metadata:
name: preview-quota
namespace: orders-pr-482
spec:
hard:
requests.cpu: "2"
requests.memory: 4Gi
limits.memory: 8Gi
persistentvolumeclaims: "2"Run previews in a dedicated cluster or at least on a dedicated node pool. They run unreviewed code, and they should never share nodes, credentials or network paths with production.
DNS and TLS without per-preview work
Creating DNS records and certificates for every pull request is slow and runs into certificate authority rate limits. Do it once instead:
- Point a wildcard record such as
*.preview.acme.devat the preview cluster's ingress load balancer. - Issue one wildcard certificate for that name with cert-manager, using a DNS-01 challenge, and set it as the ingress controller's default certificate.
- Give each preview an Ingress for
orders-pr-482.preview.acme.dev.
New previews are then reachable as soon as their Ingress exists, with no DNS propagation and no new certificate. If a preview contains several services, give each a hostname under the same pattern and pass the hostnames to the frontend as configuration.
Protect the wildcard domain. Put previews behind your identity provider with an authenticating proxy such as oauth2-proxy, or restrict them to the corporate network. Preview URLs end up in chat messages and tickets, and unreleased features should not be public.
Creating and deleting from the pipeline
The simplest implementation is a CI workflow that reacts to pull request events. It builds the image, deploys a Helm release into the preview namespace, and posts the URL back to the pull request. When the pull request closes, a second job deletes everything.
on:
pull_request:
types: [opened, synchronize, reopened, closed]
jobs:
deploy:
if: github.event.action != 'closed'
runs-on: ubuntu-latest
env:
NS: orders-pr-${{ github.event.number }}
steps:
- uses: actions/checkout@v4
- name: Build and push
run: |
docker build -t registry.internal/checkout/orders:pr-${{ github.event.number }}-${{ github.sha }} .
docker push registry.internal/checkout/orders:pr-${{ github.event.number }}-${{ github.sha }}
- name: Deploy
run: |
helm upgrade --install orders ./chart \
--namespace "$NS" --create-namespace \
--values chart/values-preview.yaml \
--set image.tag=pr-${{ github.event.number }}-${{ github.sha }} \
--set ingress.host="$NS.preview.acme.dev" \
--wait --timeout 10m
teardown:
if: github.event.action == 'closed'
runs-on: ubuntu-latest
env:
NS: orders-pr-${{ github.event.number }}
steps:
- name: Delete namespace
run: kubectl delete namespace "$NS" --ignore-not-found --wait=falseIf you already run Argo CD, the ApplicationSet pull request generator does the same job declaratively: it polls GitHub or GitLab for open pull requests, generates one Application per pull request, and deletes it when the pull request closes. CI then only builds and pushes the image.
Seeded data
A preview with an empty database is only useful for the first screen. Reviewers need realistic data to click through a change. There are three common approaches, in increasing cost:
| Approach | How | Trade-off |
|---|---|---|
| Seed scripts | Run migrations, then load fixtures from the repository | Fast and reproducible; data is only as good as the fixtures |
| Snapshot restore | Restore a sanitized snapshot into an in-cluster database | Realistic; snapshots must be refreshed and stripped of personal data |
| Database branching | Create a copy-on-write branch from a shared database, where the engine supports it | Fast to create from large datasets; ties you to that database product |
Seed scripts are the right default. They live next to the code, change with it in the same pull request, and make previews reproducible. Whatever you choose, never point a preview at a production database or a copy that still contains real customer data.
External dependencies need the same thought. Payment providers and email services usually offer sandbox accounts; use those, with credentials scoped to the preview cluster. For internal services the preview does not change, point at a shared, stable instance in the preview cluster instead of deploying the whole system each time.
Teardown you can rely on
Pull request close events get missed: a workflow fails, a repository is archived, a branch is deleted outside the normal flow. A preview that misses its teardown runs until someone notices the bill. Add a second line of defense:
- A scheduled cleanup job that lists preview namespaces, checks whether the pull request is still open through the API, and deletes the namespace if it is not.
- A maximum age, after which a preview is deleted even if the pull request is open. A new push recreates it.
- Finalizer checks, since a namespace stuck in
Terminatingusually means a custom resource with a finalizer whose controller is gone.
Keeping cost under control
Previews are idle almost all of the time. Design for that:
- Scale to zero when idle. Scale preview deployments down at night and over weekends, or use a request-driven scaler such as KEDA's HTTP add-on so a preview wakes on first request.
- Use spot or preemptible nodes for the preview node pool, with the cluster autoscaler allowed to scale it to a small floor.
- Keep quotas small, and deploy only the services the pull request changes.
- Make previews opt-in for repositories where few pull requests need one, using a label on the pull request to trigger the workflow.
- Report cost by the team label, so the teams that create previews see what they cost.
Where to start
- Pick one service whose reviewers most often ask to see a change running.
- Create a dedicated preview cluster or node pool, a wildcard DNS record and a wildcard certificate.
- Add the create and delete workflow, with namespace quotas and network policy applied by default.
- Load seed data from fixtures in the repository.
- Add the scheduled cleanup job before you roll previews out to a second service.
- Turn on scale-down for idle previews, then watch the cost report for a few weeks before expanding.