Description

Give every pull request its own short-lived environment with GitHub Actions, Helm and Kubernetes, and delete it automatically when the PR closes.

Code review has a blind spot: we review diffs, but we ship running systems.

A reviewer reads 200 changed lines and approves. Nobody ran it. A config tweak, a migration or a changed API contract can all look fine in a diff and still break in a real environment.

The usual fix is a shared staging environment. That creates new problems:

Two PRs overwrite each other's changes.
"Who's using staging right now?" becomes a Slack ritual.
Bugs get found after merge, when they are most expensive to fix.

A better pattern is to give every pull request its own short-lived environment, then delete it when the PR closes.

The idea

When a PR opens or is updated:

Build the image, tagged with the commit SHA.
Deploy it to its own Kubernetes namespace, pr-.
Post the preview URL as a PR comment.

When the PR closes or merges:

Delete the namespace. Nothing is left running and nothing costs money.

Reviewers click a link and test the change, so approval rests on evidence rather than guesswork.

Implementation (GitHub Actions + Helm)

This workflow assumes you have a Kubernetes cluster with an ingress controller, a Helm chart in ./chart that accepts image and ingress.host values, and a KUBE_CONFIG secret in your repository.

yaml
name: preview-env

on:
pull_request:
types: [opened, synchronize, reopened, closed]

env:
NAMESPACE: pr-${{ github.event.number }}
IMAGE: ghcr.io/${{ github.repository }}:${{ github.sha }}

jobs:
deploy:
if: github.event.action != 'closed'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
pull-requests: write
steps:
- uses: actions/checkout@v4

 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push image run: | docker build -t "$IMAGE" . docker push "$IMAGE" - name: Configure cluster access run: | mkdir -p ~/.kube echo "${{ secrets.KUBE_CONFIG }}" > ~/.kube/config - name: Deploy preview run: | helm upgrade --install app ./chart \ --namespace "$NAMESPACE" --create-namespace \ --set image="$IMAGE" \ --set ingress.host=pr-${{ github.event.number }}.preview.example.com - name: Comment preview URL uses: marocchino/sticky-pull-request-comment@v2 with: message: "Preview ready: https://pr-${{ github.event.number }}.preview.example.com" 

teardown:
if: github.event.action == 'closed'
runs-on: ubuntu-latest
steps:
- name: Configure cluster access
run: |
mkdir -p ~/.kube
echo "${{ secrets.KUBE_CONFIG }}" > ~/.kube/config

 - name: Delete namespace run: kubectl delete namespace "$NAMESPACE" --ignore-not-found 

You also need a wildcard DNS record, *.preview.example.com, pointing at your ingress controller. After that, each PR gets its own URL with no further setup.

Making it production-grade

The basic version works, but a few additions make it safe and cheap:

Cost control: set a ResourceQuota per namespace, and add a scheduled job that deletes namespaces older than three days in case a teardown fails.
Data: seed each environment with anonymised fixtures. Never point previews at production data.
Secrets: use a separate, low-privilege secret set for previews.
Fork safety: don't expose cluster credentials to PRs from forks. Run previews only for trusted branches.
Speed: cache Docker layers (docker/build-push-action with cache-from and cache-to) to keep deploys fast.
What changes on your team

After adopting this, the effects are easy to spot:

Fewer "works on my machine" surprises, because reviewers see a real running system.
Less contention, since there is no shared staging queue.
Defects caught before merge, where they are cheapest to fix.
Designers and product managers can check changes without a local setup.
Takeaway

Treat the environment as part of the change, not something that exists apart from it. If a PR can't be previewed, you are asking reviewers to approve something nobody has seen working.

Start small: one service, one namespace per PR, and automatic cleanup. Once your team uses it, going back to a shared staging environment gets hard.

What's the strangest bug a preview environment has caught on your team? Let me know in the comments.