PR Preview Environments

PR Preview Environments

This guide shows how to give every pull request its own live, full-stack preview environment — similar to Vercel’s preview deploys, but for a real Kubernetes application with its own cluster, ingress, and SSL. Each PR gets a dedicated cluster derived from its branch name; the cluster is created on first push, reused on subsequent pushes, and deleted automatically when the PR is closed or merged.

Prerequisites

  • rackctl installed in your CI runner (see Installation Guide)
  • A long-lived API token stored as a GitHub Actions secret (see CI/CD pipeline guide)
  • Your app’s parent domain already added and verified under DNS Mappings in the Portal (see Gateway Configuration, step 0) — every PR subdomain will fall under this
  • deployment.yaml, service.yaml, and ingress.yaml for your app, following the shape in Gateway Configuration

How it works

  1. On every pull_request event (opened, synchronize, reopened, closed), the workflow derives a stable cluster name from the repository and branch name.
  2. It builds and pushes a Docker image tagged with that same cluster name, so each PR’s image is unambiguous and traceable back to its cluster.
  3. It tries rackctl switch to the cluster. If that fails (cluster doesn’t exist yet), it creates one with rackctl create, installs NGINX Ingress, and generates SSL — but only on this first run.
  4. It renders deployment.yaml and ingress.yaml with the PR-specific image tag and hostname, then applies them.
  5. When the PR is closed (merged or not), a separate teardown job deletes the cluster.

Step 1: Derive a cluster name from the branch

Cluster and image tags must be lowercase and contain only safe characters, so the branch name is sanitized first:

jobs:
  compute-cluster-name:
    if: github.event.action != 'closed' || github.event.pull_request.merged != null
    runs-on: ubuntu-latest
    outputs:
      cluster_name: ${{ steps.name.outputs.cluster_name }}
    steps:
      - name: Derive cluster name from branch
        id: name
        run: |
          RAW="${{ github.event.repository.name }}-${{ github.head_ref }}"
          SAFE=$(echo "$RAW" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9-]+/-/g' | sed -E 's/-+/-/g' | sed -E 's/^-|-$//g' | cut -c1-63)
          echo "cluster_name=$SAFE" >> "$GITHUB_OUTPUT"

This gives every branch a unique, Kubernetes-safe cluster name, capped at 63 characters. It’s computed once in its own job so both deploy and teardown can reuse the exact same value.

Step 2: Build and push a per-PR image

Tag the image with the cluster name (not :latest) so each PR runs its own build, and also tag with the commit SHA for traceability:

  deploy:
    needs: compute-cluster-name
    if: github.event.action != 'closed'
    runs-on: ubuntu-latest
    env:
      CLUSTER_NAME: ${{ needs.compute-cluster-name.outputs.cluster_name }}
    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Log in to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKER_HUB_USERNAME }}
          password: ${{ secrets.DOCKER_PAT }}

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Build and push image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: |
            ${{ secrets.DOCKER_HUB_USERNAME }}/${{ github.event.repository.name }}:${{ env.CLUSTER_NAME }}
            ${{ secrets.DOCKER_HUB_USERNAME }}/${{ github.event.repository.name }}:${{ github.sha }}

Using the sanitized CLUSTER_NAME as the image tag (instead of latest) means kubectl apply will actually detect a change and roll out new pods on every push — no manual rollout restart needed, since the image reference itself changes each time.

Step 3: Create or reuse the cluster

      - name: Install kubectl
        uses: azure/setup-kubectl@v4
        with:
          version: 'v1.31.0'

      - name: Install rackctl
        run: |
          bash -c "$(curl -fsSL https://raw.githubusercontent.com/ginger-society/infra-as-code-repo/main/rust-helpers/installer.sh)" -- rackmint/rackctl:latest

      - name: Authenticate and generate session token
        run: |
          rackctl token-login ${{ secrets.RACKMINT_API_TOKEN }}

      - name: Try switch to existing cluster
        id: switch
        continue-on-error: true
        run: |
          rackctl switch --name "${CLUSTER_NAME}"

      - name: Create cluster (only if switch failed)
        if: steps.switch.outcome == 'failure'
        run: |
          rackctl create "${CLUSTER_NAME}" \
            --description "PR env for ${{ github.repository }}#${{ github.event.pull_request.number }}" \
            --do-not-delete
          rackctl switch --name "${CLUSTER_NAME}"

      - name: Install ingress-nginx (first-time creation only)
        if: steps.switch.outcome == 'failure'
        run: |
          kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml

      - name: Generate SSL (first-time creation only)
        if: steps.switch.outcome == 'failure'
        run: |
          rackctl gen-ssl "${CLUSTER_NAME}.k8s.rackmint.com"

--do-not-delete is passed here because the workflow’s own teardown job manages cluster lifecycle explicitly on PR close — using --ttl alongside PR-driven deletion would just create a race between the two. The steps.switch.outcome == 'failure' guard ensures ingress installation and SSL generation only run once, on the cluster’s first creation, not on every subsequent push to the same PR.

Step 4: Render and deploy manifests

Your deployment.yaml and ingress.yaml are checked into the repo with placeholder values (e.g. a default domain, and :latest as the default image tag for local/main-branch use). The workflow substitutes in the PR-specific values at deploy time without modifying the checked-in files:

      - name: Deploy application
        run: |
          sed -e "s/rackmint-demo\.k8s\.rackmint\.com/${CLUSTER_NAME}.k8s.rackmint.com/" \
              -e "s/name: ingress-prod/name: ingress-${CLUSTER_NAME}/" \
              ingress.yaml > ingress.rendered.yaml

          sed -e "s#docker.io/username/rackmint-demo:latest#docker.io/username/rackmint-demo:${CLUSTER_NAME}#" \
              deployment.yaml > deployment.rendered.yaml

          kubectl apply -f deployment.rendered.yaml
          kubectl apply -f service.yaml
          kubectl apply -f ingress.rendered.yaml

          kubectl rollout status deployment/example-deploy --timeout=120s

Note: the ingress name substitution (ingress-prodingress-${CLUSTER_NAME}) matters if you plan to reuse one shared cluster for multiple concurrent PRs; if each PR gets its own dedicated cluster (as in this guide), it’s optional but harmless.

Step 5: Tear down on PR close

A separate job, gated on the PR being closed, deletes the cluster regardless of whether it was merged:

  teardown:
    needs: compute-cluster-name
    if: github.event.action == 'closed'
    runs-on: ubuntu-latest
    env:
      CLUSTER_NAME: ${{ needs.compute-cluster-name.outputs.cluster_name }}
    steps:
      - name: Install rackctl
        run: |
          bash -c "$(curl -fsSL https://raw.githubusercontent.com/ginger-society/infra-as-code-repo/main/rust-helpers/installer.sh)" -- rackmint/rackctl:latest

      - name: Authenticate and generate session token
        run: |
          rackctl token-login ${{ secrets.RACKMINT_API_TOKEN }}

      - name: Delete PR cluster
        run: |
          rackctl delete --name "${CLUSTER_NAME}" --i-understand

--i-understand is required here since the cluster was created with --do-not-delete — this is intentional (see Step 3) and confirms the automated teardown is expected, not accidental.

Full workflow

name: Ephemeral PR Environment

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

jobs:
  compute-cluster-name:
    if: github.event.action != 'closed' || github.event.pull_request.merged != null
    runs-on: ubuntu-latest
    outputs:
      cluster_name: ${{ steps.name.outputs.cluster_name }}
    steps:
      - name: Derive cluster name from branch
        id: name
        run: |
          RAW="${{ github.event.repository.name }}-${{ github.head_ref }}"
          SAFE=$(echo "$RAW" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9-]+/-/g' | sed -E 's/-+/-/g' | sed -E 's/^-|-$//g' | cut -c1-63)
          echo "cluster_name=$SAFE" >> "$GITHUB_OUTPUT"
          echo "Resolved cluster name: $SAFE"

  deploy:
    needs: compute-cluster-name
    if: github.event.action != 'closed'
    runs-on: ubuntu-latest
    env:
      CLUSTER_NAME: ${{ needs.compute-cluster-name.outputs.cluster_name }}
    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Log in to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKER_HUB_USERNAME }}
          password: ${{ secrets.DOCKER_PAT }}

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Build and push image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: |
            ${{ secrets.DOCKER_HUB_USERNAME }}/${{ github.event.repository.name }}:${{ env.CLUSTER_NAME }}
            ${{ secrets.DOCKER_HUB_USERNAME }}/${{ github.event.repository.name }}:${{ github.sha }}

      - name: Install kubectl
        uses: azure/setup-kubectl@v4
        with:
          version: 'v1.31.0'

      - name: Install rackctl
        run: |
          bash -c "$(curl -fsSL https://raw.githubusercontent.com/ginger-society/infra-as-code-repo/main/rust-helpers/installer.sh)" -- rackmint/rackctl:latest

      - name: Authenticate and generate session token
        run: |
          rackctl token-login ${{ secrets.RACKMINT_API_TOKEN }}

      - name: Try switch to existing cluster
        id: switch
        continue-on-error: true
        run: |
          rackctl switch --name "${CLUSTER_NAME}"

      - name: Create cluster (only if switch failed)
        if: steps.switch.outcome == 'failure'
        run: |
          rackctl create "${CLUSTER_NAME}" \
            --description "PR env for ${{ github.repository }}#${{ github.event.pull_request.number }}" \
            --do-not-delete
          rackctl switch --name "${CLUSTER_NAME}"

      - name: Install ingress-nginx (first-time creation only)
        if: steps.switch.outcome == 'failure'
        run: |
          kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml

      - name: Generate SSL (first-time creation only)
        if: steps.switch.outcome == 'failure'
        run: |
          rackctl gen-ssl "${CLUSTER_NAME}.k8s.rackmint.com"

      - name: Deploy application
        run: |
          sed -e "s/rackmint-demo\.k8s\.rackmint\.com/${CLUSTER_NAME}.k8s.rackmint.com/" \
              -e "s/name: ingress-prod/name: ingress-${CLUSTER_NAME}/" \
              ingress.yaml > ingress.rendered.yaml

          sed -e "s#docker.io/username/rackmint-demo:latest#docker.io/username/rackmint-demo:${CLUSTER_NAME}#" \
              deployment.yaml > deployment.rendered.yaml

          kubectl apply -f deployment.rendered.yaml
          kubectl apply -f service.yaml
          kubectl apply -f ingress.rendered.yaml

          kubectl rollout status deployment/example-deploy --timeout=120s

  teardown:
    needs: compute-cluster-name
    if: github.event.action == 'closed'
    runs-on: ubuntu-latest
    env:
      CLUSTER_NAME: ${{ needs.compute-cluster-name.outputs.cluster_name }}
    steps:
      - name: Install rackctl
        run: |
          bash -c "$(curl -fsSL https://raw.githubusercontent.com/ginger-society/infra-as-code-repo/main/rust-helpers/installer.sh)" -- rackmint/rackctl:latest

      - name: Authenticate and generate session token
        run: |
          rackctl token-login ${{ secrets.RACKMINT_API_TOKEN }}

      - name: Delete PR cluster
        run: |
          rackctl delete --name "${CLUSTER_NAME}" --i-understand

Notes

  • Every push to the PR branch reuses the same cluster (via switch) and just redeploys — only the very first push pays the cost of cluster creation, ingress install, and SSL generation.
  • Because the image tag changes on every push (tied to the cluster name plus a github.sha tag for traceability), kubectl apply always sees a genuine diff and rolls out fresh pods — no rollout restart needed, unlike a fixed :latest tag.
  • If your team runs many concurrent PRs, factor the per-cluster resource cost (CPU/RAM/disk) into your --cpu-limit/--ram-limit/--disk-size choices, or use --it during manual testing to right-size a cluster interactively.
  • This pattern still assumes unit and mocked-dependency tests run earlier in CI — treat the PR preview environment as your integration/end-to-end layer, not a replacement for fast unit tests.