Skip to content

PR Preview Clusters with ksail-cluster Action

KSail can provision short-lived, per-PR preview Kubernetes clusters in GitHub Actions using:

  • ksail-cluster composite action — one-step cluster provisioning with caching and GitOps CI support
  • validate input — validates manifests before cluster creation
  • delete: true input — always-on cluster cleanup, even on failure

This pattern positions KSail as a lightweight alternative to tools like mirrord-preview for teams who want GitOps-native preview environments backed by real Kubernetes clusters.

The following workflow creates an ephemeral cluster on every pull request, validates manifests, deploys via GitOps, runs smoke tests, then tears the cluster down:

.github/workflows/pr-preview.yaml
name: PR Preview Cluster
on:
pull_request:
branches: [main]
jobs:
preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Provision preview cluster
id: cluster
uses: devantler-tech/ksail/.github/actions/ksail-cluster@main
with:
validate: "true" # validate manifests before cluster creation
push: "true" # push manifests to OCI registry for GitOps
reconcile: "true" # trigger GitOps reconcile and wait
delete: "true" # delete cluster when done (runs even on failure)
- name: Run smoke tests
env:
KUBECONFIG: ${{ steps.cluster.outputs.kubeconfig }}
run: |
kubectl get pods -A
# ... your smoke/integration tests here

For teams using Flux or ArgoCD, the full GitOps preview pattern includes manifest validation, OCI push, and reconcile:

.github/workflows/pr-preview.yaml
name: PR Preview Cluster
on:
pull_request:
branches: [main]
paths:
- "k8s/**"
- "ksail.yaml"
jobs:
preview:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # required if pushing to GHCR
steps:
- uses: actions/checkout@v4
- name: Provision preview cluster with GitOps
id: cluster
uses: devantler-tech/ksail/.github/actions/ksail-cluster@main
with:
distribution: K3s # K3s starts fastest; swap for Vanilla, Talos, or VCluster
validate: "true" # fail fast if manifests are invalid
push: "true" # push manifests to the cluster's local OCI registry
reconcile: "true" # wait for Flux/ArgoCD to reconcile successfully
delete: "true" # always clean up
sops-age-key: ${{ secrets.SOPS_AGE_KEY }} # optional: decrypt SOPS secrets
- name: Run integration tests
env:
KUBECONFIG: ${{ steps.cluster.outputs.kubeconfig }}
run: |
# Tests run against the fully reconciled cluster
kubectl get pods -n my-app -l app=my-app
curl -f http://localhost:8080/healthz || exit 1

Combine --config with the preview pattern to test against a staging-like config:

- name: Provision staging-like preview cluster
id: cluster
uses: devantler-tech/ksail/.github/actions/ksail-cluster@main
with:
config: ksail.staging.yaml # use staging config (Talos + Cilium + Kyverno)
init: "false" # config already exists in the repo, skip reinit
validate: "true"
push: "true"
reconcile: "true"
delete: "true"

See Multi-Environment Workflows for the full --config guide.

When running without delete: true (e.g., you want to keep the cluster alive for debugging a failed CI run), add an explicit cleanup step using if: always():

steps:
- uses: actions/checkout@v4
- name: Provision preview cluster
id: cluster
uses: devantler-tech/ksail/.github/actions/ksail-cluster@main
with:
push: "true"
reconcile: "true"
# NOTE: delete is intentionally omitted here — handled below
- name: Run smoke tests
env:
KUBECONFIG: ${{ steps.cluster.outputs.kubeconfig }}
run: ./scripts/smoke-test.sh
- name: Delete preview cluster
if: always() # runs even when smoke tests fail
run: ksail cluster delete

The action outputs the kubeconfig file path in steps.<id>.outputs.kubeconfig. Pass it to subsequent steps via KUBECONFIG:

- name: Deploy additional resources
env:
KUBECONFIG: ${{ steps.cluster.outputs.kubeconfig }}
run: |
kubectl apply -f manifests/smoke-test-job.yaml
kubectl wait job/smoke-test --for=condition=complete --timeout=120s
kubectl logs job/smoke-test

You can also set it globally for all subsequent steps:

- name: Set KUBECONFIG globally
run: echo "KUBECONFIG=${{ steps.cluster.outputs.kubeconfig }}" >> "$GITHUB_ENV"
InputDefaultDescription
distributionVanillaCluster distribution (Vanilla, K3s, Talos, VCluster)
providerDockerInfrastructure provider (Docker, Hetzner)
config""Path to alternate ksail.yaml (e.g. ksail.staging.yaml)
validatefalseRun ksail workload validate before cluster creation (after init, when enabled)
pushfalseRun ksail workload push after creation
reconcilefalseRun ksail workload reconcile after push
deletefalseDelete cluster at the end (if: always())
args""Extra args passed to cluster init (and cluster create when init is skipped)
inittrueRun ksail cluster init before create
installtrueInstall KSail from releases (set to false if already in PATH)
cachetrueCache Helm charts and mirror registry volumes
ksail-versionlatestKSail version to install (e.g. 5.55.0)
sops-age-key""SOPS Age private key for secret decryption
hcloud-token""Hetzner Cloud API token (required when provider is Hetzner)
hosts-file""Path to a hosts file to append to /etc/hosts
root-ca-cert-file""Path to a root CA certificate to install as trusted

Action output: kubeconfig — path to the kubeconfig file for the provisioned cluster.

  • K3s starts fastest — use distribution: K3s for PRs where spin-up time matters.

  • Pin the action version using a tag (e.g. @v5.73.0) for reproducible CI, or @main for bleeding-edge.

  • Cache hits speed up CI significantly — the cache: true default restores Helm charts and mirror registry volumes, avoiding repeated Docker Hub pulls.

  • Combine with path filters (paths: in on.pull_request) to skip the preview cluster when only non-Kubernetes files changed.

  • Validate early — set validate: true to fail fast on invalid manifests before spending time on cluster provisioning.

  • Export logs before deletion — if tests fail, capture pod logs before the cluster is deleted:

    - name: Capture logs on failure
    if: failure()
    env:
    KUBECONFIG: ${{ steps.cluster.outputs.kubeconfig }}
    run: kubectl logs -n my-app -l app=my-app --tail=100 || true