Sign in
← Kubernetes locally
9 min · easy · Lesson 3 of 8

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:

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

browser localhost:30080 hostPort kind node containerPort 30080 nodePort Service nodePort 30080 targetPort Pod targetPort 8080
Same number both times: containerPort in the kind config equals nodePort in the Service.

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.

Done reading?

Mark the lesson as read to move your track progress forward.

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.

Get access for $49

Tell the agent: use MCP izi, give me lesson k8s-local-03-kind