このページはまだ翻訳されていません。英語版を表示しています。
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
rackctlinstalled 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, andingress.yamlfor your app, following the shape in Gateway Configuration
How it works
- On every
pull_requestevent (opened, synchronize, reopened, closed), the workflow derives a stable cluster name from the repository and branch name. - 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.
- It tries
rackctl switchto the cluster. If that fails (cluster doesn’t exist yet), it creates one withrackctl create, installs NGINX Ingress, and generates SSL — but only on this first run. - It renders
deployment.yamlandingress.yamlwith the PR-specific image tag and hostname, then applies them. - When the PR is closed (merged or not), a separate
teardownjob 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_NAMEas the image tag (instead oflatest) meanskubectl applywill actually detect a change and roll out new pods on every push — no manualrollout restartneeded, 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-prod→ingress-${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.shatag for traceability),kubectl applyalways sees a genuine diff and rolls out fresh pods — norollout restartneeded, unlike a fixed:latesttag. - If your team runs many concurrent PRs, factor the per-cluster resource cost (CPU/RAM/disk) into your
--cpu-limit/--ram-limit/--disk-sizechoices, or use--itduring 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.