Home Posts Ephemeral Environments for PR Preview Deploys [2026]
Cloud Infrastructure

Ephemeral Environments for PR Preview Deploys [2026]

Ephemeral Environments for PR Preview Deploys [2026]
Dillip Chowdary
Dillip Chowdary
Tech Entrepreneur & Innovator · May 21, 2026 · 9 min read

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-482 and a single host like pr-482.preview.example.com.
  • Use helm upgrade --install --create-namespace --atomic to keep preview deploys repeatable and rollback-safe.
  • Trigger deploys on opened, synchronize, and reopened, then delete the namespace on closed.
  • 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.

Pro tip: If your app needs reviewable fixtures, seed lightweight masked data during startup rather than cloning a full production-sized database into every namespace.

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

  1. Forked pull requests cannot access the same secrets. GitHub documents that secrets are not passed to workflows triggered from forks, and GITHUB_TOKEN is read-only there. If you accept external contributions, split preview deploys behind a trusted pull_request_target path or use a maintainer-approved deploy step.
  2. 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.
  3. Wildcard DNS does not match your host pattern. A wildcard like *.preview.example.com covers one label only. If you switch to api.pr-482.preview.example.com, you need a different DNS and certificate strategy.
Watch out: Do not point preview environments at live production services by default. Isolation is the whole point; otherwise you are only moving staging drift into a prettier URL.

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? +
Use a workflow triggered by 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? +
For most teams, a separate namespace per PR is the practical default because it is cheaper and easier to clean up automatically. A separate cluster only makes sense when you need strong isolation boundaries, cluster-level feature testing, or materially different infrastructure per branch.
Why does my GitHub Actions preview deploy fail on forked pull requests? +
GitHub documents that standard secrets are not passed to workflows triggered from forks, and the forked PR token is read-only. That means registry pushes and cluster deploys often fail unless you route the deploy through a trusted workflow design and review the security implications carefully.
What is the safest Helm command for repeatable preview deploys? +
For this use case, 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.

Found this useful? Share it.