Ephemeral Environments for PR Preview Deploys [2026]
Bottom Line
The cleanest preview-deploy pattern is one pull request, one image tag, one Kubernetes namespace, and one predictable URL. Once that contract is fixed, GitHub Actions and Helm can create and destroy full-stack environments with very little custom logic.
Key Takeaways
- ›Map every PR to a single namespace like
pr-482and a single host likepr-482.preview.example.com. - ›Use
helm upgrade --install --create-namespace --atomicto keep preview deploys repeatable and rollback-safe. - ›Trigger deploys on
opened,synchronize, andreopened, then delete the namespace onclosed. - ›Forked PRs need special handling because GitHub does not pass normal secrets to those runs.
Ephemeral environments stop code review from being a screenshot exchange. Instead of arguing about whether a branch “works on staging,” every pull request gets its own frontend, API, and data path under an isolated preview URL. The implementation below uses a simple contract: build images per PR, deploy them into a dedicated Kubernetes namespace with Helm, and tear the whole stack down automatically when the pull request closes.
Prerequisites
What you need before you start
- A Kubernetes cluster with an Ingress controller and wildcard DNS for a preview domain such as
*.preview.example.com. - A GitHub repository using GitHub Actions, plus cluster credentials available to the deploy job.
- Helm 3 and kubectl available on the runner that performs the deploy.
- A container registry. This walkthrough uses GHCR because GitHub documents first-party authentication with
${{ secrets.GITHUB_TOKEN }}. - Seed data that is safe for review environments. If your preview database mirrors production shape, mask it first with the Data Masking Tool.
Bottom Line
Treat previews as short-lived releases, not special cases. One PR number should deterministically produce one namespace, one host, and one deploy command.
Step 1: Define the preview contract
Most preview systems become fragile because naming is inconsistent across CI, DNS, Helm, and cleanup jobs. Fix that first. For each pull request, derive the same three values everywhere:
- Namespace:
pr-482 - Release name:
pr-482 - Host:
pr-482.preview.example.com
This host format matters. Kubernetes documents wildcard hosts, but the wildcard only covers a single DNS label, so *.preview.example.com matches pr-482.preview.example.com and not deeper shapes.
Derive values once
PR_NUMBER=482
NAMESPACE=pr-${PR_NUMBER}
RELEASE=pr-${PR_NUMBER}
HOST=pr-${PR_NUMBER}.preview.example.com
TAG=pr-${PR_NUMBER}-a1b2c3d4
That gives you a stable identity for frontend, backend, and any namespaced dependencies. Avoid random suffixes unless you truly need multiple previews per pull request; they make cleanup harder and troubleshooting slower.
Step 2: Template the stack with Helm
Use one chart for the full preview stack so you can install or remove it as a single release. In practice, that usually means a web deployment, an API deployment, Services, an Ingress, ConfigMaps, and optionally a small database or cache if your app cannot reuse shared lower environments.
Start with values that CI can override
global:
prNumber: ''
web:
image:
repository: ''
tag: ''
api:
image:
repository: ''
tag: ''
preview:
host: ''
ingress:
className: nginx
Template names from the release
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-api
spec:
replicas: 1
selector:
matchLabels:
app: {{ .Release.Name }}-api
template:
metadata:
labels:
app: {{ .Release.Name }}-api
spec:
containers:
- name: api
image: '{{ .Values.api.image.repository }}:{{ .Values.api.image.tag }}'
env:
- name: PREVIEW_PR
value: '{{ .Values.global.prNumber }}'
Expose the preview with a host-specific Ingress
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ .Release.Name }}
spec:
ingressClassName: {{ .Values.ingress.className }}
rules:
- host: '{{ .Values.preview.host }}'
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ .Release.Name }}-web
port:
number: 80
Keep the chart boring. Preview environments fail more often from clever conditional logic than from missing features. If a resource should exist for every preview, put it in the chart and let CI supply only the variables that actually change per PR.
Step 3: Automate build, deploy, and cleanup
GitHub recommends the pull_request event for PR workflows and documents a closed event you can use for cleanup. It also documents concurrency, which prevents two runs for the same PR from fighting each other.
Build images and deploy on active pull requests
name: Preview Environment
on:
pull_request:
types: [opened, synchronize, reopened, closed]
concurrency:
group: preview-${{ github.event.pull_request.number }}
cancel-in-progress: true
env:
REGISTRY: ghcr.io
IMAGE_NAME_WEB: ${{ github.repository }}-web
IMAGE_NAME_API: ${{ github.repository }}-api
jobs:
deploy:
if: github.event.action != 'closed'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v6
- name: Log in to GHCR
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push web
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
with:
context: ./apps/web
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_WEB }}:pr-${{ github.event.pull_request.number }}-${{ github.sha }}
- name: Build and push api
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
with:
context: ./apps/api
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_API }}:pr-${{ github.event.pull_request.number }}-${{ github.sha }}
- name: Deploy preview
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
GIT_SHA: ${{ github.sha }}
BASE_DOMAIN: preview.example.com
run: |
NAMESPACE=pr-${PR_NUMBER}
RELEASE=pr-${PR_NUMBER}
HOST=pr-${PR_NUMBER}.${BASE_DOMAIN}
helm upgrade --install "${RELEASE}" ./deploy/helm/app \
-n "${NAMESPACE}" \
--create-namespace \
--atomic \
--set-string global.prNumber="${PR_NUMBER}" \
--set-string web.image.repository="${REGISTRY}/${IMAGE_NAME_WEB}" \
--set-string web.image.tag="pr-${PR_NUMBER}-${GIT_SHA}" \
--set-string api.image.repository="${REGISTRY}/${IMAGE_NAME_API}" \
--set-string api.image.tag="pr-${PR_NUMBER}-${GIT_SHA}" \
--set-string preview.host="${HOST}"
kubectl wait -n "${NAMESPACE}" --for=condition=available deployment/"${RELEASE}-web" --timeout=180s
kubectl wait -n "${NAMESPACE}" --for=condition=available deployment/"${RELEASE}-api" --timeout=180s
{
echo '### Preview ready'
echo
echo "https://${HOST}"
} >> "${GITHUB_STEP_SUMMARY}"
Delete the namespace when the pull request closes
cleanup:
if: github.event.action == 'closed'
runs-on: ubuntu-latest
steps:
- name: Delete preview namespace
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
kubectl delete namespace "pr-${PR_NUMBER}" --ignore-not-found=true
The critical command here is helm upgrade --install. Helm’s current docs also confirm that --create-namespace works when --install is set, and --atomic automatically enables waiting and rolls back failed upgrades. That combination keeps preview deploys idempotent even when developers push multiple commits in quick succession.
Verification and expected output
After you open or update a pull request, validate the result from both GitHub and the cluster.
What you should see in GitHub Actions
- A single workflow run for the PR because concurrency cancels older in-flight runs.
- A job summary that prints the preview URL.
- Separate image pushes for the web and API services, both tagged with the PR number and commit SHA.
What you should see in Kubernetes
kubectl get namespace pr-482
helm list -n pr-482
kubectl get ingress -n pr-482
kubectl get deploy -n pr-482
Expected shape:
NAME STATUS AGE
pr-482 Active 2m
NAME NAMESPACE REVISION STATUS CHART
pr-482 pr-482 1 deployed app
NAME CLASS HOSTS ADDRESS
pr-482 nginx pr-482.preview.example.com 203.0.113.10
If the Ingress is healthy and DNS is correct, opening the preview host should route to the branch-specific frontend and API stack, not your shared staging environment.
Troubleshooting and What's Next
Troubleshooting: top 3 failure modes
- Forked pull requests cannot access the same secrets. GitHub documents that secrets are not passed to workflows triggered from forks, and
GITHUB_TOKENis read-only there. If you accept external contributions, split preview deploys behind a trustedpull_request_targetpath or use a maintainer-approved deploy step. - Namespaces get stuck in Terminating. Kubernetes notes that namespace deletion is asynchronous. In practice, stuck finalizers are usually the cause, so inspect namespaced resources before you reach for forceful cleanup.
- Wildcard DNS does not match your host pattern. A wildcard like
*.preview.example.comcovers one label only. If you switch toapi.pr-482.preview.example.com, you need a different DNS and certificate strategy.
What's next
- Add a PR comment bot that posts the preview URL and deployment status back into the review thread.
- Attach a small disposable database per namespace if your integration tests need write isolation.
- Promote the same chart into shared staging and production so preview deploys exercise the real release path.
- Publish a short runbook for engineers covering retries, cleanup, and expected cost controls.
The main design choice is not Kubernetes versus another platform. It is whether your preview system behaves like a deterministic release pipeline. Once it does, ephemeral environments become an operational tool instead of a demo feature.
Frequently Asked Questions
How do I create a preview environment for every pull request? +
pull_request events to build branch-specific images, then deploy them into a namespace derived from the PR number. A predictable contract like pr-482 for the namespace and pr-482.preview.example.com for the host keeps deploy and cleanup logic simple.Should preview deploys use a separate Kubernetes namespace or a separate cluster? +
Why does my GitHub Actions preview deploy fail on forked pull requests? +
What is the safest Helm command for repeatable preview deploys? +
helm upgrade --install is the right baseline because it handles both first deploys and updates. Adding --create-namespace and --atomic makes the release more resilient by creating the namespace when needed and rolling back failed upgrades automatically.Get Engineering Deep-Dives in Your Inbox
Weekly breakdowns of architecture, security, and developer tooling — no fluff.