Installing Kubernetes with kind
Run multi-node Kubernetes in Docker with kind. Cluster config, loading local images, port mappings for Ingress, pinning versions, and using it in CI.
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.
Install
# 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.kind
It needs a working Docker or Podman installation, and nothing else.
A first cluster
kind create cluster --name dev
Creating 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 nodes
NAME 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
Multi-node, which is the point
# kind-config.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
- role: worker
- role: worker
kind create cluster --name dev --config kind-config.yaml
Create 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.
Pinning the Kubernetes version
nodes:
- role: control-plane
image: kindest/node:v1.30.6
- role: worker
image: kindest/node:v1.30.6
Pin it to match the cluster you actually deploy to. Particularly useful for testing an upgrade before it happens.
Loading local images
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 dev
Set 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 tarball
Forgetting kind load is the single most common kind problem, and it is worth adding to any build script that targets kind.
Ingress
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: worker
kind 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=90s
localhost 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.
Mounting host directories
Useful for iterating on code without rebuilding images:
nodes:
- role: control-plane
extraMounts:
- hostPath: /Users/me/work/payments
containerPath: /src
The path is then available to a hostPath volume inside the cluster.
kind in CI
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.sh
Cluster 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.
Debugging
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 inspection
If 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 or minikube?
| 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.
Quick reference
| 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 |
Key takeaways
- Each node is a Docker container, which is why kind starts and deletes so quickly.
kind load docker-imageis mandatory for local images: the cluster cannot see your host's Docker store.- Set
imagePullPolicy: IfNotPresent, or the kubelet tries a registry and fails. - Create multi-node clusters. Single-node hides affinity, spread and drain behaviour entirely.
extraPortMappingsmust be set at creation time and are what make Ingress reachable on localhost.LoadBalancerstays<pending>without MetalLB: use Ingress orport-forwardinstead.- kind in CI catches admission, RBAC and manifest errors that linting cannot.
Check your understanding
Ingress unreachable from the host
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?
Anti-affinity that does nothing
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?
Frequently Asked Questions
What is kind and how is it different from minikube?
kind runs each Kubernetes node as a Docker container rather than in a VM, which makes cluster creation and deletion very fast and multi-node configurations trivial. That speed is why it is the usual choice for CI. minikube is somewhat slower but ships curated add-ons, so Ingress and metrics-server are one command each rather than a manual install. The practical split is kind for speed, multi-node and pipelines; minikube for a batteries-included local environment.
Why does my locally built image give ImagePullBackOff in kind?
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.
How do I create a multi-node cluster with kind?
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.
How do I use an Ingress controller with kind?
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.
Why is my LoadBalancer Service pending in kind?
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.
Can I use kind in CI pipelines?
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.