How to Set Up a Kubernetes Development Environment with Kind

How to Set Up a Kubernetes Development Environment with Kind

For a long time I tested manifests directly against a shared dev cluster, which meant every broken YAML or half-finished CRD was visible to the entire team, and cleaning up after myself was a constant chore. Switching to Kind (Kubernetes IN Docker) for local development fixed that almost overnight — spin up a disposable cluster, break things freely, tear it down, repeat.

What Is Kind?

Kind runs Kubernetes clusters using Docker containers as “nodes.” It was originally built for testing Kubernetes itself, but it’s become one of the most popular ways to run local development clusters because it’s fast, scriptable, and doesn’t need a VM or hypervisor — just Docker.

Compare it to alternatives:

Kind’s biggest strength is multi-node clusters that behave like real clusters — including control-plane/worker separation — all running as sibling Docker containers on your laptop.

Prerequisites

Installation

# macOS
brew install kind

# Linux
curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.23.0/kind-linux-amd64
chmod +x ./kind
sudo mv ./kind /usr/local/bin/kind

# Verify
kind version

Creating a Basic Cluster

kind create cluster --name dev-cluster
Creating cluster "dev-cluster" ...
 ✓ Ensuring node image (kindest/node:v1.30.0) 🖼
 ✓ Preparing nodes 📦
 ✓ Writing configuration 📜
 ✓ Starting control-plane 🕹️
 ✓ Installing CNI 🔌
 ✓ Installing StorageClass 💾
Set kubectl context to "kind-dev-cluster"
kubectl cluster-info --context kind-dev-cluster
kubectl get nodes
NAME                        STATUS   ROLES           AGE   VERSION
dev-cluster-control-plane   Ready    control-plane   45s   v1.30.0

Multi-Node Cluster Configuration

Single-node clusters miss important behavior — scheduling decisions, node affinity, pod distribution. A multi-node config file gives you a much more realistic testbed:

# kind-config.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
  kubeadmConfigPatches:
  - |
    kind: InitConfiguration
    nodeRegistration:
      kubeletExtraArgs:
        node-labels: "ingress-ready=true"
  extraPortMappings:
  - containerPort: 80
    hostPort: 80
    protocol: TCP
  - containerPort: 443
    hostPort: 443
    protocol: TCP
- role: worker
- role: worker
- role: worker
kind create cluster --name multi-node-dev --config kind-config.yaml
kubectl get nodes
NAME                              STATUS   ROLES           AGE   VERSION
multi-node-dev-control-plane      Ready    control-plane   60s   v1.30.0
multi-node-dev-worker             Ready    <none>          45s   v1.30.0
multi-node-dev-worker2            Ready    <none>          45s   v1.30.0
multi-node-dev-worker3            Ready    <none>          45s   v1.30.0

The extraPortMappings block maps host ports 80/443 to the control-plane container, which is what makes local Ingress testing actually work from your browser.

Setting Kubernetes Version

Kind uses “node images” that bundle a specific Kubernetes version — useful for testing compatibility before upgrading a real cluster:

kind create cluster --name test-129 --image kindest/node:v1.29.4

Installing an Ingress Controller

With the port mappings from above, install ingress-nginx configured for Kind:

kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
kubectl wait --namespace ingress-nginx \
  --for=condition=ready pod \
  --selector=app.kubernetes.io/component=controller \
  --timeout=120s

Now a standard Ingress resource routes real traffic from localhost:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: demo-app
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /
spec:
  ingressClassName: nginx
  rules:
  - http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: demo-app-service
            port:
              number: 80
kubectl apply -f demo-ingress.yaml
curl http://localhost/

Loading Local Docker Images

The most common friction point with local development is testing images you just built without pushing to a registry. Kind solves this directly:

docker build -t myapp:dev .
kind load docker-image myapp:dev --name dev-cluster
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  replicas: 1
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      containers:
      - name: myapp
        image: myapp:dev
        imagePullPolicy: Never
      resources:
        requests:
          cpu: "100m"
          memory: "128Mi"

imagePullPolicy: Never is essential here — it tells the kubelet to use the image already loaded into the Kind node rather than attempting (and failing) to pull from a registry.

Persistent Storage in Kind

Kind ships with a default local-path StorageClass, which is enough for most development testing:

kubectl get storageclass
NAME                 PROVISIONER            RECLAIMPOLICY
standard (default)   rancher.io/local-path  Delete
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: dev-data
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 1Gi

For simulating cloud-provider-specific storage classes (EBS, GCE PD), you’ll need to mock them or accept that storage testing has limits in Kind — that’s a genuine gap versus a real cloud cluster.

Integrating Kind into CI/CD

Kind is widely used in GitHub Actions and GitLab CI for testing manifests and Helm charts against a real (if ephemeral) API server before merging:

# .github/workflows/test.yaml
name: Kubernetes Manifest Tests
on: [pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4
    - name: Create Kind cluster
      uses: helm/kind-action@v1
      with:
        cluster_name: ci-test
    - name: Deploy and test
      run: |
        kubectl apply -f k8s/
        kubectl wait --for=condition=available --timeout=120s deployment/myapp
        kubectl run test-curl --image=curlimages/curl --rm -it --restart=Never -- curl -s http://myapp-service

This pattern catches broken manifests, misconfigured probes, or bad RBAC before they ever hit staging.

Using Helm with Kind

helm repo add bitnami https://charts.bitnami.com/bitnami
helm install my-redis bitnami/redis --set auth.enabled=false --namespace dev --create-namespace
kubectl get pods -n dev

Kind clusters are perfect for iterating on your own Helm charts before shipping them:

helm install myapp ./charts/myapp --namespace dev
helm upgrade myapp ./charts/myapp --namespace dev
helm uninstall myapp --namespace dev

Deleting and Recreating Clusters

Because Kind clusters are meant to be disposable:

kind get clusters
kind delete cluster --name dev-cluster

A common workflow script:

#!/bin/bash
set -e
kind delete cluster --name dev-cluster 2>/dev/null || true
kind create cluster --name dev-cluster --config kind-config.yaml
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
kubectl wait --namespace ingress-nginx --for=condition=ready pod --selector=app.kubernetes.io/component=controller --timeout=120s
echo "Cluster ready."

Troubleshooting

# Cluster won't start — check Docker resources
docker system df
docker system prune

# Node stuck NotReady
kubectl describe node <node-name>
docker logs <node-container-name>

# Can't reach services via Ingress
docker ps  # confirm port mappings are bound correctly on the control-plane container

Networking Between Kind Clusters

Occasionally you need two separate Kind clusters to talk to each other — testing cross-cluster service mesh federation, for instance. Since each Kind cluster’s nodes are Docker containers, they share the host’s Docker network (by default the kind bridge network unless configured otherwise), meaning containers across different Kind clusters can often reach each other directly by container IP:

docker network inspect kind | jq '.[0].Containers'

For anything beyond quick experimentation, this is fragile and not something to rely on for real multi-cluster testing — a dedicated multi-cluster tool or a proper service mesh testing setup is a better investment once the need becomes recurring rather than a one-off curiosity.

Common Mistakes

Simulating Production-Like Conditions Locally

One underused capability is applying real resource constraints and node labels so your local cluster more closely mirrors production scheduling behavior. You can label Kind nodes after creation just like real nodes:

kubectl label node multi-node-dev-worker disktype=ssd
kubectl label node multi-node-dev-worker2 zone=us-east-1a
kubectl label node multi-node-dev-worker3 zone=us-east-1b

This lets you actually test node affinity rules, taints, and topology spread constraints locally before ever pushing to a shared cluster — catching scheduling misconfigurations at development time instead of during a deploy review.

kubectl taint node multi-node-dev-worker3 dedicated=batch:NoSchedule

Combined with resource requests on your test Deployments, you can reproduce Pending pod scenarios, verify your affinity/taint YAML actually behaves as expected, and iterate in seconds rather than waiting on a shared cluster’s scheduler.

Debugging Tools Inside a Kind Cluster

Since Kind nodes are just Docker containers, you have direct access for deeper debugging that’s often restricted on managed cloud nodes:

docker exec -it multi-node-dev-control-plane bash
crictl ps
crictl logs <container-id>

crictl talks directly to the container runtime inside the Kind node — useful for diagnosing issues below the kubelet’s abstraction layer, such as confirming whether a container actually started versus the kubelet just failing to report status correctly.

Working With kubectl Contexts

Every kind create cluster call automatically creates and switches to a new kubeconfig context, prefixed kind-. When juggling multiple local clusters alongside a real one, keep track explicitly:

kubectl config get-contexts
kubectl config use-context kind-dev-cluster

A useful habit is naming Kind clusters after the feature branch or ticket you’re testing, so kubectl config get-contexts stays meaningful instead of accumulating a pile of identically-named kind-dev-cluster entries from past sessions you forgot to delete.

Summary

Kind gives you fast, disposable, genuinely multi-node Kubernetes clusters running entirely in Docker — ideal for local development, manifest/Helm chart testing, and CI pipelines. It won’t replace a cloud cluster for testing cloud-provider-specific integrations, but for iterating on application code, RBAC, scheduling behavior, and Ingress routing, it’s hard to beat the speed of kind create cluster and kind delete cluster.

References

Exit mobile version