kind, Kubernetes IN Docker, runs each cluster node as a Docker container. Clusters start in well under a minute, multi-node is a few lines of config, and deleting one is instant.
That speed is why it has become the default for CI pipelines and for anyone who recreates clusters often.
# macOS
brew install kind
# Linux
curl -Lo ./kind https://kind.sigs.k8s.io/dl/latest/kind-linux-amd64
chmod +x ./kind && sudo mv ./kind /usr/local/bin/kind
# Windows
winget install Kubernetes.kindIt needs a working Docker or Podman installation, and nothing else.
kind create cluster --name devCreating cluster "dev" ...
â Ensuring node image (kindest/node:v1.31.2)
â Preparing nodes
â Starting control-plane
â Installing CNI
â Installing StorageClass
Set kubectl context to "kind-dev"
kubectl get nodesNAME STATUS ROLES AGE VERSION
dev-control-plane Ready control-plane 32s v1.31.2
Note the kubectl context is kind-dev. Kind prefixes every context name with kind-.
kind get clusters
kind delete cluster --name dev# kind-config.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
- role: worker
- role: workerkind create cluster --name dev --config kind-config.yamlCreate a multi-node cluster even for simple work. Single-node clusters hide everything interesting about scheduling: pod anti-affinity does nothing, topology spread constraints do nothing, ReadWriteOnce volume conflicts never appear, and node drains are meaningless. Those are exactly the behaviours that surprise people in a real cluster.
nodes:
- role: control-plane
image: kindest/node:v1.30.6
- role: worker
image: kindest/node:v1.30.6Pin it to match the cluster you actually deploy to. Particularly useful for testing an upgrade before it happens.
This is the one piece of friction with kind. Each node is a Docker container with its own containerd image store, so your host's images are invisible to it. A Pod referencing a locally built image gets ImagePullBackOff.
docker build -t myapp:dev .
kind load docker-image myapp:dev --name devSet imagePullPolicy: IfNotPresent, since Always, the default when the tag is latest or absent, makes the kubelet attempt a registry pull that will fail.
kind load image-archive myapp.tar --name dev # from a saved tarballForgetting kind load is the single most common kind problem, and it is worth adding to any build script that targets kind.
Reaching an Ingress from your host needs port mappings declared at cluster creation. They cannot be added afterwards:
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: workerkind create cluster --config kind-config.yaml
kubectl apply -f https://raw.githubusercontent.com/kubernetes-sigs/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=90slocalhost then reaches your Ingress. The ingress-ready=true label is what the kind-specific ingress-nginx manifest uses to place the controller on the node with the port mappings.
LoadBalancer Services stay <pending> on kind unless you install MetalLB. For local work, Ingress or kubectl port-forward is simpler.
Useful for iterating on code without rebuilding images:
nodes:
- role: control-plane
extraMounts:
- hostPath: /Users/me/work/payments
containerPath: /srcThe path is then available to a hostPath volume inside the cluster.
kind is the standard way to run integration tests against a real Kubernetes API:
# .github/workflows/test.yml
name: test
on: [push]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: helm/kind-action@v1
with:
cluster_name: ci
config: kind-config.yaml
- run: docker build -t myapp:ci .
- run: kind load docker-image myapp:ci --name ci
- run: kubectl apply -f manifests/
- run: kubectl wait --for=condition=available deployment/myapp --timeout=120s
- run: ./run-integration-tests.shCluster creation adds well under a minute, which is what makes this practical on every push. Testing against a real API server catches admission, RBAC and manifest errors that no amount of YAML linting will.
kubectl get pods -A # control plane should all be Running
docker ps # each node is a container
docker exec -it dev-control-plane bash # shell onto a node
kind export logs ./kind-logs --name dev # everything, for inspectionIf a cluster will not create at all, it is usually Docker: not running, out of disk, or out of memory. docker system prune and raising Docker Desktop's memory allocation resolve most cases.
| kind | minikube | |
|---|---|---|
| Startup | Very fast | Slower |
| Multi-node | Trivial | Supported |
| Add-ons | Install them yourself | One command each |
| Local images | kind load docker-image | eval $(minikube docker-env) |
| LoadBalancer | Needs MetalLB | minikube tunnel |
| CI | Designed for it | Workable |
kind for speed, multi-node and CI. minikube if you want Ingress and metrics-server without assembling them.
| Task | Command |
|---|---|
| Create | kind create cluster --name dev |
| From config | kind create cluster --config kind-config.yaml |
| List | kind get clusters |
| Load an image | kind load docker-image <image> --name dev |
| Kubeconfig | kind get kubeconfig --name dev |
| Shell onto a node | docker exec -it dev-control-plane bash |
| Export logs | kind export logs ./logs --name dev |
| Delete | kind delete cluster --name dev |
kind load docker-image is mandatory for local images: the cluster cannot see your host's Docker store.imagePullPolicy: IfNotPresent, or the kubelet tries a registry and fails.extraPortMappings must be set at creation time and are what make Ingress reachable on localhost.LoadBalancer stays <pending> without MetalLB: use Ingress or port-forward instead.<MCQExercise exercise={{ id: 'k8s-kind-ports-after', title: 'Ingress unreachable from the host', description: 'An ingress-nginx controller is running in a kind cluster, but curl localhost times out. The cluster was created with kind create cluster with no config file. What is wrong?', options: [ { id: 'a', text: 'No extraPortMappings were declared, and they can only be set when the cluster is created. The cluster must be recreated with them' }, { id: 'b', text: 'The ingress controller needs to be exposed with a LoadBalancer Service' }, { id: 'c', text: 'kind does not support Ingress at all' }, { id: 'd', text: 'The Ingress resource is missing an ingressClassName' }, ], correctId: 'a', explanation: 'kind nodes are Docker containers, so host ports reach them only through published port mappings, and Docker fixes those when the container is created. extraPortMappings in the cluster config is how you declare them, and adding them later means recreating the cluster. Since kind clusters take under a minute to build, that is a small cost.', wrongExplanations: { b: 'A LoadBalancer Service on kind stays pending without MetalLB, so it would not help. Port mappings are the mechanism that actually works here.', c: 'kind supports Ingress well and the project publishes an ingress-nginx manifest specifically for it. The missing piece is host networking, not support.', d: 'A missing ingressClassName would leave the Ingress unclaimed with an empty ADDRESS. Here the controller is running and the failure is reaching it from outside.', }, }} />
<MCQExercise exercise={{ id: 'k8s-kind-single-node', title: 'Anti-affinity that does nothing', description: 'A Deployment with podAntiAffinity requiring replicas on different nodes runs three replicas happily on a single-node kind cluster. Why does the rule appear ignored?', options: [ { id: 'a', text: 'It is not ignored, with one node the rule can only be satisfied by leaving replicas Pending, so a soft rule is simply disregarded and a hard rule would block scheduling' }, { id: 'b', text: 'kind does not implement affinity rules' }, { id: 'c', text: 'Anti-affinity only applies to StatefulSets' }, { id: 'd', text: 'The rule needs a topology key of hostname, which kind does not set' }, ], correctId: 'a', explanation: 'This is exactly why single-node clusters are misleading. A preferred rule is best-effort and quietly ignored when it cannot be met, while a required rule would leave two replicas Pending. Neither outcome tells you whether the rule is correct. Creating a kind cluster with two or three workers makes the behaviour visible before production does.', wrongExplanations: { b: 'kind runs an unmodified Kubernetes scheduler and implements affinity exactly as any other cluster does.', c: 'Anti-affinity works with any workload controller. StatefulSets are merely where people encounter it most often.', d: 'kind sets kubernetes.io/hostname on its nodes like any distribution. With one node, every replica shares the same value regardless.', }, }} />
Because kind nodes are Docker containers with their own containerd image store, and your host's Docker images are not visible inside them. Run kind load docker-image myapp:dev --name <cluster> to copy it in. Also set imagePullPolicy: IfNotPresent, since Always, which is the default when the tag is latest or omitted, makes the kubelet try a registry pull that fails. Forgetting kind load is the most common kind problem by a wide margin.
Write a config file listing the nodes you want, with one control-plane role and one or more worker roles, then pass it with kind create cluster --config kind-config.yaml. It is worth doing even for simple work: on a single node, pod anti-affinity and topology spread constraints have no observable effect, ReadWriteOnce volume conflicts never occur, and node drains are meaningless. All of which then surprise you in a real cluster.
Declare extraPortMappings for ports 80 and 443 in the cluster config, and label the control-plane node with ingress-ready=true. These must be set when the cluster is created, because Docker fixes published ports at container creation. Adding them later means recreating the cluster. Then apply the kind-specific ingress-nginx manifest, which schedules the controller onto the labelled node, and localhost will reach your Ingress.
There is no cloud provider to fulfil the request, so the external IP is never assigned. Install MetalLB if you genuinely need LoadBalancer Services to work, but for local development an Ingress with extraPortMappings, or simply kubectl port-forward, is considerably less setup for the same result.
Yes, and it is the standard approach for integration testing against a real Kubernetes API. helm/kind-action sets up a cluster in a GitHub Actions job in under a minute, after which you build your image, kind load it, apply your manifests, and run tests. Because it is a genuine API server, it catches admission controller rejections, RBAC problems and invalid manifests that YAML linting cannot detect.
Install kubectl, stay within the supported version skew, connect to a cluster with kubeconfig, enable shell completion and add useful krew plugins.
Learn the kubectl commands you will run daily: get, apply, describe, delete, selectors, output formats, namespaces and tips to work faster with kubeconfig.
Inspect cluster resources with kubectl get and describe. Use -o wide, jsonpath and custom-columns, read events, and know when to choose get over describe.