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:
- Minikube — VM or container-based, more batteries-included (dashboard, addons), slightly heavier.
- k3d — runs k3s in Docker, extremely lightweight, great for resource-constrained machines.
- Kind — closest to upstream Kubernetes behavior, excellent for multi-node cluster simulation and CI pipelines.
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
- Docker (or Podman) installed and running
kubectlinstalled- At least 4GB RAM free for a multi-node setup
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
- Forgetting
imagePullPolicy: Neverafterkind load docker-image— Kubernetes tries to pull from a registry and fails withErrImagePull. - Not setting
extraPortMappingsand then wondering why Ingress traffic never reaches the cluster. - Running resource-heavy multi-node clusters on a laptop with insufficient Docker memory allocation — check Docker Desktop’s resource settings if pods are getting OOMKilled unexpectedly.
- Treating Kind as production-equivalent for cloud-specific features (LoadBalancer services, EBS storage) — these need mocking or acceptance that Kind isn’t a full cloud simulator.
- Forgetting to reload images after every code change — this is the single most common “why isn’t my change showing up” issue.
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.
