kind: a cluster made of containers
How kind brings up Kubernetes inside Docker, how to go multi-node, expose ports to the outside, and get a working Ingress.
kind stands for Kubernetes IN Docker. What would be a separate machine in a regular cluster becomes a container here: one container per node. Inside it run containerd, kubelet, and everything else a node needs.
Why
A kind cluster comes up in tens of seconds and goes away with one command, because deleting a container is cheap. All that's left on disk afterward is the node image. That makes kind handy when you only need a cluster for an hour: to try out a manifest, run tests in CI, break it, and start over.
The project started as a test bed for Kubernetes itself, and that still shows in its release pace: v0.31.0 in December 2025, v0.32.0 in June 2026, v0.33.0 in August 2026.
Versions and node images
The current kind version, v0.33.0, came out on August 26, 2026. By default
it creates a cluster on Kubernetes 1.37.0, image kindest/node:v1.37.0.
v0.33.0 ships images for four Kubernetes versions:
kindest/node:v1.37.0kindest/node:v1.36.4kindest/node:v1.35.8kindest/node:v1.34.11
A node image is built for a specific kind release. The release notes list
a digest (@sha256:…) for every image, and the authors ask you to pin the
image together with it: that's the only way to be sure you get the image
built for your kind version. An image that's too new for an older kind may
not come up at all: kindest/node:v1.37.0 needs kind v0.33.0 or newer.
There's a requirement that trips people up on Apple Silicon: the node image's platform has to match the host's platform. An arm64 machine needs an arm64 image, emulation doesn't help here.
The default runtime is Docker 20.10 or newer. Podman 3.0+ and nerdctl 1.7+ also work, you switch between them with an environment variable:
KIND_EXPERIMENTAL_PROVIDER=podman kind create cluster
Podman's default pids limit is 4096. That's not enough for a cluster:
processes hit the limit, and Pods stop coming up.
Values for the variable: docker, podman, nerdctl. About memory: the
docs mention 6-8 GB RAM, but that's a requirement for building your own
node images. The clusters in this lesson's labs fit in a 3 GB limit, though
it's best to give Docker Desktop at least 4 GB.
Installation
brew install kind # macOS
curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.33.0/kind-linux-amd64 # Linux x86_64
curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.33.0/kind-linux-arm64 # Linux arm64
chmod +x ./kind && sudo mv ./kind /usr/local/bin/kind
winget install Kubernetes.kind # Windows
In WSL2, kind installs inside the Linux distribution, same as on regular
Linux. To check: kind version prints the version, for example
kind v0.31.0 go1.25.5 darwin/arm64.
Cluster lifecycle
kind create cluster
kind create cluster --name lab --image kindest/node:v1.35.0@sha256:452d707d4862f52530247495d180205e029056831160e22870e37e3f6c1ac31f
kind get clusters
kind delete cluster --name lab
Without flags the cluster gets the name kind. --image sets the
Kubernetes version, kind get clusters shows what's currently running on
the machine.
kind adds the new cluster's context to ~/.kube/config itself and makes
it current. If you don't want to touch your working file, give the
cluster its own:
kind create cluster --name lab --kubeconfig ./lab-kubeconfig
The same flag on kind delete cluster removes the entry from that file.
For a cluster that already exists, export its kubeconfig with
kind export kubeconfig --name lab --kubeconfig ./lab-kubeconfig.
Multiple nodes
One node covers most scenarios, but it's awkward for studying the scheduler. To get more than one node, you need a config file:
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
- role: worker
- role: worker
Run it with this file:
kind create cluster --config kind-multi.yaml
Each entry in nodes becomes a separate container. kind reads the node
layout only at creation time: there's no "add a node" command for a live
cluster, so you rebuild it instead.
Single-node and multi-node clusters differ in one more way. When there's
just one node, kind removes the control-plane taint
node-role.kubernetes.io/control-plane:NoSchedule, otherwise there'd be
nowhere to place a Pod. When there are worker nodes, the taint stays, and
regular Pods go to a worker.
Ports to the outside
The cluster lives in containers, and containers don't expose their ports
to the host by default. On macOS and Windows this step is required,
without it you can't reach the services. You map ports with
extraPortMappings in the config:
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
extraPortMappings:
- containerPort: 80
hostPort: 80
listenAddress: "127.0.0.1"
protocol: TCP
Under the hood this is an ordinary Docker container port publication,
visible in docker ps's PORTS column and in docker port <node name>.
You can't change it on a running container, only by rebuilding the
cluster.
NodePort is a special case. There's a catch here that costs people time:
containerPort in the kind config and nodePort in the Service have to
match. The port mapping happens once, at cluster creation, while the
Service shows up later, so it's the Service that has to be adjusted to fit
the port that's already open. nodePort is picked from the range
30000-32767; for any other value the api-server replies with
provided port is not in the valid range.
LoadBalancer and Ingress
A Service of type LoadBalancer in kind will just hang without an external address on its own: there's no one to hand addresses out. That role is taken by a separate binary, cloud-provider-kind, currently at v0.12.0 from October 2, 2026.
Starting with v0.9.0 (October 27, 2025) it serves not just LoadBalancer
but Ingress too. kind's docs have said plainly ever since: "No
third-party ingress controllers are required by default." You don't need
to install a separate ingress controller. Gateway API is included too,
the channel is picked with the --gateway-channel flag, values
standard, experimental or disabled.
brew install cloud-provider-kind
kind create cluster
cloud-provider-kind # keep it running
kubectl get ingress # ADDRESS gets filled in
On Linux, grab the binary from the
releases page.
The cloud-provider-kind process has to keep running the whole time you
need an external address. Close the terminal, and addresses stop being
handed out.
The full example from kind's docs:
kind create cluster
kubectl apply -f https://kind.sigs.k8s.io/examples/ingress/usage.yaml
INGRESS_IP=$(kubectl get ingress example-ingress -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
curl ${INGRESS_IP}/foo
One detail about single-node clusters. Pods in one land on the control-plane, and the load balancer excludes nodes like that by label. You need to remove the label:
kubectl label node kind-control-plane node.kubernetes.io/exclude-from-external-load-balancers-
The hyphen at the end of the label name means removal, it's not a typo.
On macOS and Windows, kind nodes aren't reachable from the host directly,
so cloud-provider-kind turns on --enable-lb-port-mapping mode. It spins
up a helper container named something like kindccm-… and attaches an
ephemeral host port to it. You need to hit localhost:<that port>, not
the address that kubectl get svc shows.
Storage
kind ships a StorageClass out of the box: standard, marked as the
default. A PersistentVolumeClaim with no class specified picks it up
automatically.
kubectl get storageclass
NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION AGE
standard (default) rancher.io/local-path Delete WaitForFirstConsumer false 44s
Common mistakes
NodePort doesn't match the port mapping. curl to the host port gets
an empty response or a connection reset, even though the Pod is running.
Forgot --kubeconfig. The new cluster becomes the current context in
~/.kube/config, and your next kubectl command goes somewhere other than
where you expected. Check kubectl config current-context before making
changes.
The wrong node image. An image without a digest, or from a different kind version, might not come up. Get the image from your kind version's release notes.
Not enough inotify. The symptom is vague: Pods don't come up, and component logs show scattered errors about watching files. Fixed on the host:
fs.inotify.max_user_watches=524288
fs.inotify.max_user_instances=512
Rootless mode. Requires cgroup v2 and cpu controller delegation. With systemd 252 and newer, delegation turns on by itself; on older systems you have to configure it by hand.
Kernel modules not loaded. In rootless setups, ip_tables and
iptable_nat are often not loaded. The cluster's networking breaks
entirely as a result, and the error message doesn't point at the cause.
WSL2 and xt_recent. The WSL2 kernel has no xt_recent module.
Because of that, sessionAffinity: ClientIP doesn't work, and it's not
just the Service with that setting that breaks, but Services created
after it too.
What you'll need for the tasks
Both labs bring up a kind cluster on your laptop: lab 1 needs up to 3 GB of Docker memory (two nodes), lab 2 needs up to 1.5 GB (one node). You need Docker 20.10+, kind v0.31.0 or newer, and kubectl.
- Lab 1: a config with a
nodeslist (section "Multiple nodes"), the--name,--config,--image,--kubeconfigflags onkind create clusterandkind delete cluster,kubectl run,kubectl get pod -o wide. - Lab 2: the rule "containerPort equals nodePort" (section "Ports to the
outside"),
docker port,kubectl get svc,kubectl editorkubectl patch,curl.
Done reading?
Mark the lesson as read to move your track progress forward.
Lesson marked as done.
Go through it with an AI agent
The agent explains anything unclear in plain words, gives you 2 labs for this lesson and drills you on interview questions. $49 one-time, lifetime access.
Progress saved in this browser
Clear your history or open this on your phone, and the marks will be gone. Leave your email and they'll move to your account. No password needed.
Done. moved to your account. You can see your progress on any device. My progress