Back to Distributed Systems & Kubernetes Series

Kind Track Part 2: Multi-Node & CI Usage

June 6, 2026 Wasil Zafar 32 min read

Kind's killer feature is CI integration. A GitHub Actions job can spin up a full multi-node cluster, deploy your application, run end-to-end tests, and tear down — all in about 2 minutes. This article shows how to structure that workflow.

Table of Contents

  1. Multi-Node Config
  2. Networking
  3. Kind in GitHub Actions
  4. Loading Images into Kind
  5. Flux & Kind
  6. Exercises
  7. Key Takeaways

Multi-Node Config

Control-Plane & Workers

# kind-multinode.yaml — production-like topology for CI
apiVersion: kind.x-k8s.io/v1alpha4
kind: Cluster
name: ci-cluster

nodes:
  # Single control-plane (HA control-planes are possible but slow to create in CI)
  - role: control-plane
    image: kindest/node:v1.30.2
    kubeadmConfigPatches:
      - |
        kind: InitConfiguration
        nodeRegistration:
          kubeletExtraArgs:
            node-labels: "ingress-ready=true"
    extraPortMappings:
      - containerPort: 80
        hostPort: 80
        protocol: TCP
      - containerPort: 443
        hostPort: 443
        protocol: TCP

  # Two worker nodes
  - role: worker
    image: kindest/node:v1.30.2

  - role: worker
    image: kindest/node:v1.30.2

Worker Labels via kubeadm Patches

# Label workers at cluster creation time using kubeadmConfigPatches
nodes:
  - role: worker
    kubeadmConfigPatches:
      - |
        kind: JoinConfiguration
        nodeRegistration:
          kubeletExtraArgs:
            node-labels: "topology.kubernetes.io/zone=us-east-1a,workload=backend"

  - role: worker
    kubeadmConfigPatches:
      - |
        kind: JoinConfiguration
        nodeRegistration:
          kubeletExtraArgs:
            node-labels: "topology.kubernetes.io/zone=us-east-1b,workload=frontend"
# Create cluster from config
kind create cluster --config kind-multinode.yaml

# Verify worker labels
kubectl get nodes --show-labels | grep workload
# ci-cluster-worker    Ready   worker   60s   v1.30.2   ...,workload=backend,...
# ci-cluster-worker2   Ready   worker   55s   v1.30.2   ...,workload=frontend,...

Networking

ExtraPortMappings for Ingress

# Install nginx ingress controller configured for Kind
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml

# Wait for ingress controller to become ready
kubectl wait --namespace ingress-nginx \
  --for=condition=ready pod \
  --selector=app.kubernetes.io/component=controller \
  --timeout=120s

# Deploy a test app
kubectl create deployment webapp --image=nginx --port=80
kubectl expose deployment webapp --port=80

# Create an Ingress
cat <<EOF | kubectl apply -f -
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: webapp-ingress
spec:
  ingressClassName: nginx
  rules:
    - http:
        paths:
          - path: /webapp
            pathType: Prefix
            backend:
              service:
                name: webapp
                port:
                  number: 80
EOF

# Access through localhost (extraPortMappings mapped 80 → container 80)
curl http://localhost/webapp

NodePort Exposure

# Expose as NodePort and access via port-forward (simplest for CI)
kubectl expose deployment webapp --type=NodePort --port=80

# Get the assigned NodePort
NODE_PORT=$(kubectl get svc webapp -o jsonpath='{.spec.ports[0].nodePort}')
echo "NodePort: $NODE_PORT"

# Access via the Kind container IP
KIND_IP=$(docker inspect ci-cluster-control-plane \
  --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}')
curl http://$KIND_IP:$NODE_PORT/

# Or use kubectl port-forward (works in any network context including CI)
kubectl port-forward svc/webapp 8080:80 &
curl http://localhost:8080/
kill %1  # stop port-forward

Kind in GitHub Actions

Full Workflow YAML

# .github/workflows/e2e.yml
name: E2E Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  e2e:
    runs-on: ubuntu-latest
    timeout-minutes: 15

    steps:
      # 1. Check out source
      - name: Checkout code
        uses: actions/checkout@v4

      # 2. Set up Go (for Go-based controllers)
      - name: Set up Go
        uses: actions/setup-go@v5
        with:
          go-version: "1.22"

      # 3. Create Kind cluster (uses engageintellect/kind-action or engineerd/setup-kind)
      - name: Create Kind cluster
        uses: helm/kind-action@v1.10.0
        with:
          version: v0.23.0
          kubectl_version: v1.30.2
          cluster_name: ci-cluster
          config: ./kind-config.yaml   # your multi-node config file

      # 4. Build the application image
      - name: Build image
        run: |
          docker build -t myapp:${{ github.sha }} .
          docker tag myapp:${{ github.sha }} myapp:latest

      # 5. Load image into Kind (avoids registry push)
      - name: Load image into Kind
        run: kind load docker-image myapp:${{ github.sha }} --name ci-cluster

      # 6. Deploy the application
      - name: Deploy application
        run: |
          kubectl apply -f k8s/
          kubectl set image deployment/myapp myapp=myapp:${{ github.sha }}

      # 7. Wait for rollout to complete
      - name: Wait for deployment
        run: |
          kubectl rollout status deployment/myapp --timeout=120s
          kubectl wait pod \
            --for=condition=ready \
            --selector=app=myapp \
            --timeout=120s

      # 8. Run integration/E2E tests
      - name: Run E2E tests
        run: |
          # Port-forward in background
          kubectl port-forward svc/myapp 8080:80 &
          sleep 3  # wait for port-forward to establish

          # Run your test suite
          go test ./tests/e2e/... -v -timeout 5m \
            -base-url=http://localhost:8080

      # 9. Capture debug info on failure
      - name: Debug on failure
        if: failure()
        run: |
          kubectl get all -A
          kubectl describe pods --all-namespaces
          kubectl logs -l app=myapp --tail=50 || true

Matrix Testing Against Multiple Kubernetes Versions

# Test against multiple Kubernetes versions in parallel
jobs:
  e2e-matrix:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        k8s-version:
          - v1.28.9
          - v1.29.4
          - v1.30.2

    steps:
      - uses: actions/checkout@v4

      - name: Create Kind cluster (k8s ${{ matrix.k8s-version }})
        uses: helm/kind-action@v1.10.0
        with:
          node_image: kindest/node:${{ matrix.k8s-version }}
          cluster_name: test-${{ matrix.k8s-version }}

      - name: Run tests
        run: |
          kubectl apply -f k8s/
          kubectl rollout status deployment/myapp --timeout=120s
          go test ./tests/... -v
CI image caching: GitHub Actions caches Docker layer pulls per runner. The kindest/node image is ~700 MB but is cached after the first pull on a given runner. To minimise pull time, use a pinned image digest in your Kind config rather than a floating tag.

Loading Images into Kind

# ── Method 1: kind load docker-image ──────────────────────────
# For images already built in the CI environment
docker build -t myapp:ci .
kind load docker-image myapp:ci --name ci-cluster

# Load multiple images at once
kind load docker-image myapp:ci sidecar:ci --name ci-cluster

# ── Method 2: Local registry (for faster iteration) ───────────
# Start registry (once per workflow, reused across steps)
docker run -d -p 5001:5000 --name kind-registry --restart=always registry:2

# Connect to Kind network
docker network connect kind kind-registry

# Push and pull normally (no need for kind load)
docker build -t localhost:5001/myapp:ci .
docker push localhost:5001/myapp:ci

# In your Deployment:
# image: localhost:5001/myapp:ci
# imagePullPolicy: Always  (or IfNotPresent)

# ── Method 3: Kind archive loading (largest images) ─────────
# Export image to tarball, then archive-load (most reliable for 1GB+ images)
docker save myapp:ci -o /tmp/myapp.tar
kind load image-archive /tmp/myapp.tar --name ci-cluster

Flux & Kind

Kind is an excellent environment for testing Flux GitOps workflows locally or in CI. You can bootstrap Flux onto a Kind cluster and verify that your Kustomization and HelmRelease objects reconcile correctly before pushing to a staging cluster.

# Install Flux CLI
curl -s https://fluxcd.io/install.sh | sudo bash

# Check Kind cluster satisfies Flux prerequisites
flux check --pre

# Bootstrap Flux onto Kind cluster using a Git repo
# (Replace with your real GitHub repo and token)
flux bootstrap github \
  --owner=your-org \
  --repository=gitops-config \
  --branch=main \
  --path=clusters/kind-ci \
  --personal \
  --token-auth

# Flux creates its own namespace and starts reconciling
kubectl get pods -n flux-system
# NAME                                      READY   STATUS   RESTARTS
# helm-controller-xxxxxxxxx-xxxxx           1/1     Running  0
# kustomize-controller-xxxxxxxxx-xxxxx      1/1     Running  0
# notification-controller-xxxxxxxxx-xxxxx   1/1     Running  0
# source-controller-xxxxxxxxx-xxxxx         1/1     Running  0

# Watch reconciliation
flux get all
# clusters/kind-ci/myapp.yaml — a Kustomization for Kind CI testing
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: myapp
  namespace: flux-system
spec:
  interval: 1m
  path: ./apps/myapp/overlays/dev
  prune: true
  sourceRef:
    kind: GitRepository
    name: gitops-config
  healthChecks:
    - apiVersion: apps/v1
      kind: Deployment
      name: myapp
      namespace: default
  timeout: 2m
# Force a reconciliation (useful in CI after pushing changes)
flux reconcile kustomization myapp --with-source

# Wait for specific Kustomization to be ready
kubectl wait kustomization/myapp \
  -n flux-system \
  --for=condition=Ready \
  --timeout=120s

Exercises

Exercise 1 — Full CI Workflow: Create a GitHub repository with a simple Go HTTP server, a Dockerfile, Kubernetes manifests, and a GitHub Actions workflow using helm/kind-action. The workflow should build the image, load it into Kind, deploy, wait for rollout, and verify the HTTP endpoint returns 200. Make a code change, push, and watch the CI pipeline pass.
Exercise 2 — Matrix K8s Versions: Extend your GitHub Actions workflow with a matrix strategy testing against Kubernetes v1.28, v1.29, and v1.30. Confirm all three pass. Then introduce a manifest that uses a feature only available in v1.29+ and verify the v1.28 job fails with a clear error.
Exercise 3 — Flux on Kind: Install Flux CLI and bootstrap Flux onto a local Kind cluster using a test GitHub repo. Create a simple Kustomization pointing to a dev overlay. Push a manifest change to the repo and watch Flux reconcile it in real time with flux get all -w. Verify with kubectl get pods that the updated deployment is running.

Key Takeaways

Key Takeaways:
  • Kind multi-node clusters: define all nodes in kind-config.yaml and use kubeadmConfigPatches to set worker labels
  • extraPortMappings bind container ports to host ports — map 80/443 on the control-plane for ingress controller access
  • helm/kind-action is the standard GitHub Action; it creates, configures, and provides kubectl in one step
  • Load images with kind load docker-image in CI (no registry needed); use a local registry for repeated iteration in dev
  • Matrix testing against multiple Kubernetes versions runs in parallel and catches API deprecation early
  • Flux bootstraps onto Kind in minutes — use it to validate GitOps configurations before promoting to staging