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"
Input Default Description
distribution Vanilla Cluster distribution (Vanilla, K3s, Talos, VCluster, KWOK)
provider Docker Infrastructure provider (Docker, Hetzner)
config "" Path to alternate ksail.yaml (e.g. ksail.staging.yaml)
validate false Run ksail workload validate before cluster creation (after init, when enabled)
scan false Run ksail workload scan (can be enabled independently of validate)
scan-framework nsa Comma-separated security frameworks to scan against (e.g. nsa,mitre)
scan-compliance-threshold 0 Fail if compliance score is below this threshold (0–100)
scan-format pretty-printer Scan output format (pretty-printer, json, sarif, junit)
scan-output "" File path to write scan results to (defaults to stdout)
scan-path "" Path to scan (defaults to active config’s sourceDirectory or k8s/)
scan-verbose false Show all scanned resources in output, not just failed ones
push false Run ksail workload push after creation
reconcile false Run ksail workload reconcile after push
delete false Delete cluster at the end (if: always())
args "" Extra args passed to cluster init (and cluster create when init is skipped)
init true Run ksail project init before create
install true Install KSail from releases (set to false if already in PATH)
cache true Cache Helm charts and mirror registry volumes
ksail-version latest KSail 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 release tag (e.g. @v7.56.0) or a commit SHA for reproducible CI, or @main for bleeding-edge — there is currently no floating major tag like @v7.

  • 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