Lesson 02 of 66 · 13 min read · Updated

Setting Up a Local Multi-Node Cluster with kind and Docker Desktop

In the previous article we looked inside a four-node cluster: one control-plane node and three workers. Many Kubernetes features only make sense with more than one node. Pods spread across workers, DaemonSets put one copy on every node, affinity rules and topology spread constraints choose between nodes. A single-node minikube or Docker Desktop cluster hides all of that.

This article shows two ways to build a multi-node cluster on your own machine. Both are based on kind (Kubernetes IN Docker), which runs each Kubernetes node as a Docker container. The first way is Docker Desktop's built-in Kubernetes with the kind provisioner. The second is the kind CLI with a config file, which works anywhere Docker runs.

We will cover the following topics:

  1. How kind turns Docker containers into Kubernetes nodes
  2. Creating a multi-node cluster in Docker Desktop
  3. Creating the same cluster with the kind CLI and a config file
  4. Port mappings, node labels and pinned Kubernetes versions
  5. Getting locally built .NET images into the cluster, and the stale-image trap
  6. Reaching LoadBalancer Services from your machine
  7. Switching contexts, resetting and deleting clusters

Technical Requirements

  1. Docker Desktop on macOS or Windows, or Docker Engine on Linux.
  2. About 4 CPUs and 8 GB of memory available to Docker for a four-node cluster. Each node runs its own kubelet and containerd.
  3. kubectl. Docker Desktop installs it for you.
  4. The .NET 10 SDK to build the sample image.
  5. For the CLI part: the kind binary. The examples use kind v0.33.0.

Core Concepts

What kind Actually Does

kind was built to test Kubernetes itself, and that is why it is so faithful. Each node is a Docker container started from a kindest/node image. Inside that container run systemd, containerd and the kubelet, exactly like on a real machine. The control-plane node additionally runs the API server, etcd, the scheduler and the controller manager as static Pods, created by kubeadm.

All node containers join a Docker network called kind, so they reach each other like machines on the same LAN. Your Pods then run as containers inside those node containers.

LayerWhat it is on kind
MachineA Docker container (kindest/node:v1.36.x)
Container runtimecontainerd inside that container
CNIkindnet
Storagelocal-path-provisioner, StorageClass standard
LoadBalancerNot built in. Docker Desktop adds one, plain kind needs cloud-provider-kind

Docker Desktop: kubeadm or kind

Docker Desktop offers two ways to provision its built-in Kubernetes:

ProvisionerNodesUse it when
kubeadmOne node onlyYou want the simplest possible local cluster
kindOne or more nodes, with a choice of Kubernetes versionYou need several nodes or a specific version

The kind provisioner requires the containerd image store in Docker Desktop. It does not work with the classic Docker image store. If the option is greyed out, enable the containerd image store first under Settings → General.

Two Clusters, Same Shape

Docker Desktop (kind)kind CLI
Created fromDocker Desktop dashboardkind create cluster --config
Context namedocker-desktopkind-<cluster-name>
Node namesdesktop-control-plane, desktop-worker, desktop-worker2, ...<name>-control-plane, <name>-worker, ...
Local imagesServed to the nodes automaticallykind load docker-image
LoadBalancer ServicesWork out of the box, reachable on localhostNeed cloud-provider-kind or a NodePort with a port mapping
Config fileNo, settings in the UIYes, versioned in Git

Docker Desktop is the most convenient choice for daily work. The CLI is the better choice when the cluster definition must be reproducible, for example in CI or for a team.

How Local Images Reach the Nodes

This is the part that confuses most .NET developers. You run docker build -t orders-api:1.0 . on your machine, but the nodes have their own containerd inside their containers. They do not automatically see your Docker images.

  1. Docker Desktop solves this for you. Every node is configured with a registry mirror, registry-mirror:1273, which serves the images from Docker Desktop's image store. When a node needs orders-api:1.0, it pulls it from the mirror in milliseconds.
  2. Plain kind has no such mirror. You copy the image into every node with kind load docker-image orders-api:1.0 --name <cluster>.

In both cases the node keeps a copy of the image. That leads to the trap shown in the step-by-step example: rebuild an image under the same tag, and with imagePullPolicy: IfNotPresent the nodes keep running the old copy.

Pinning the Kubernetes Version

Every kind release has a default node image. kind v0.33.0 defaults to Kubernetes v1.37.0. To get another version, pass a node image with its digest, taken from the release notes:

kind create cluster --image kindest/node:v1.36.4@sha256:099e049362a1526b2db71494e1947aae99bd16290d7c895f2b7ea312e3cbfaed

Always use the digest. The tag alone may point to an image built for a different kind version.

Hands-On: Kubernetes Commands

List contexts and switch between clusters

Each cluster is a context in your kubeconfig.

kubectl config get-contexts
kubectl config use-context docker-desktop

Show the images Docker Desktop uses for Kubernetes

The Docker Desktop CLI plugin lists the images of both provisioners.

docker desktop kubernetes images list

List kind clusters and their nodes

Works for clusters created with the kind CLI.

kind get clusters
kind get nodes --name lab

Load a local image into a kind cluster

Copies the image into the containerd of every node.

kind load docker-image hello-local:1.0 --name lab

List images inside a node

crictl runs inside every kind node.

docker exec desktop-worker crictl images | grep hello-local

Write the kubeconfig somewhere else

--kubeconfig keeps a throwaway cluster out of your main ~/.kube/config.

kind create cluster --name lab --kubeconfig ./lab.kubeconfig

Delete a kind cluster

Removes the node containers and the context.

kind delete cluster --name lab

Step-by-Step Example

The Scenario

We create a four-node cluster in Docker Desktop, deploy a .NET 10 API built on our machine, reach it through a LoadBalancer Service, and walk into the stale-image trap on purpose. Then we build an equivalent cluster with the kind CLI from a config file.

Step 1 — Create the Cluster in Docker Desktop

  1. Open the Docker Desktop Dashboard and select the Kubernetes view.
  2. Select Create cluster.
  3. Choose kind as the provisioner.
  4. Pick the Kubernetes version and set the number of nodes to 4. Docker Desktop creates one control-plane node and three workers.
  5. Leave Show system containers (advanced) off unless you want to see the node containers in docker ps.
  6. Select Create and wait until the status shows the cluster as running.

Docker Desktop writes a context called docker-desktop into your kubeconfig and selects it.

Step 2 — Verify the Nodes

kubectl get nodes -o wide
NAME STATUS ROLES AGE VERSION INTERNAL-IP OS-IMAGE CONTAINER-RUNTIME
desktop-control-plane Ready control-plane 7d13h v1.36.1 172.18.0.7 Debian GNU/Linux 13 (trixie) containerd://2.3.1
desktop-worker Ready <none> 7d13h v1.36.1 172.18.0.5 Debian GNU/Linux 13 (trixie) containerd://2.3.1
desktop-worker2 Ready <none> 7d13h v1.36.1 172.18.0.10 Debian GNU/Linux 13 (trixie) containerd://2.3.1
desktop-worker3 Ready <none> 7d13h v1.36.1 172.18.0.6 Debian GNU/Linux 13 (trixie) containerd://2.3.1

The INTERNAL-IP addresses come from the kind Docker network. The control-plane node carries a NoSchedule taint, so your workloads land on the three workers.

The node containers are hidden from docker ps by default, but they exist and you can open a shell in them:

docker inspect desktop-control-plane --format '{{.Config.Image}} {{.State.Status}} {{.HostConfig.NetworkMode}}'
kindest/node:v1.36.1 running kind

Step 3 — Build a .NET Image Locally

Create a minimal API:

dotnet new web -n HelloLocal
cd HelloLocal

Replace Program.cs:

var app = WebApplication.CreateBuilder(args).Build();
app.MapGet("/", () => new { message = "Hello from a locally built image", version = "1.0", host = Environment.MachineName });
app.Run();

Add a Dockerfile next to the project folder:

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY HelloLocal/ .
RUN dotnet publish HelloLocal.csproj -c Release -o /app

FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app .
ENV ASPNETCORE_HTTP_PORTS=8080
EXPOSE 8080
ENTRYPOINT ["dotnet", "HelloLocal.dll"]
docker build -t hello-local:1.0 .

The image only exists in Docker Desktop's image store. Nothing has been pushed to a registry.

Step 4 — Deploy It with a LoadBalancer Service

Save as hello-local.yaml:

apiVersion: v1
kind: Namespace
metadata:
name: arch-kind
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: hello-local
namespace: arch-kind
spec:
replicas: 3
selector:
matchLabels:
app: hello-local
template:
metadata:
labels:
app: hello-local
spec:
containers:
- name: hello-local
image: hello-local:1.0
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
name: hello-local
namespace: arch-kind
spec:
type: LoadBalancer
selector:
app: hello-local
ports:
- port: 18150
targetPort: 8080
kubectl apply -f hello-local.yaml
kubectl rollout status deploy/hello-local -n arch-kind
kubectl get pods,svc -n arch-kind -o wide
NAME READY STATUS RESTARTS AGE IP NODE
pod/hello-local-7c9854cbfd-7k66w 1/1 Running 0 0s 10.244.2.102 desktop-worker
pod/hello-local-7c9854cbfd-rcf7t 1/1 Running 0 0s 10.244.1.100 desktop-worker2
pod/hello-local-7c9854cbfd-xc6cd 1/1 Running 0 0s 10.244.4.109 desktop-worker3

NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
service/hello-local LoadBalancer 10.96.210.163 172.18.0.12 18150:32487/TCP 0s

Each worker got one replica, and the events show where the image came from:

kubectl events -n arch-kind | grep Pulled
Normal Pulled Pod/hello-local-7c9854cbfd-7k66w Successfully pulled image "hello-local:1.0" in 157ms (157ms including waiting). Image size: 92452909 bytes.

157 milliseconds for a 92 MB image: the node pulled it from Docker Desktop's local registry mirror, not from the internet. You can see the mirror in the node's containerd configuration:

docker exec desktop-worker cat /etc/containerd/certs.d/_default/hosts.toml
[host."http://registry-mirror:1273"]
capabilities = ["pull", "resolve"]
skip_verify = true

Step 5 — Call the LoadBalancer from Your Machine

The Service got an EXTERNAL-IP from Docker Desktop's built-in cloud provider. That address lives on the kind Docker network, but Docker Desktop also publishes the Service port on localhost:

curl http://localhost:18150/
{"message":"Hello from a locally built image","version":"1.0","host":"hello-local-7c9854cbfd-xc6cd"}

Pick Service ports that are free on your machine. Two LoadBalancer Services cannot both use port 80 on localhost.

Step 6 — Walk into the Stale-Image Trap

Change the version string in Program.cs to "1.0 (rebuilt)", rebuild under the same tag, and restart the Deployment:

docker build -t hello-local:1.0 .
kubectl rollout restart deploy/hello-local -n arch-kind
kubectl rollout status deploy/hello-local -n arch-kind
curl http://localhost:18150/
{"message":"Hello from a locally built image","version":"1.0","host":"hello-local-6ffc8bc7f6-ccpb9"}

New Pods, old code. The events explain why:

kubectl events -n arch-kind | grep "already present"
Normal Pulled Pod/hello-local-6ffc8bc7f6-xll4n Container image "hello-local:1.0" already present on machine and can be accessed by the pod

With IfNotPresent the kubelet found hello-local:1.0 in the node's own cache and never asked the mirror again.

Step 7 — Get Out of the Trap

There are two clean fixes:

  1. Use a new tag for every build (1.0.1, a Git SHA, a timestamp). This is what you should do in real pipelines anyway, because a tag then always means the same bits.
  2. Use the Always pull policy (imagePullPolicy: Always) while you iterate locally. The kubelet then asks the mirror every time, which costs only milliseconds on Docker Desktop.
kubectl patch deploy hello-local -n arch-kind --type=json \
-p='[{"op":"replace","path":"/spec/template/spec/containers/0/imagePullPolicy","value":"Always"}]'
kubectl rollout status deploy/hello-local -n arch-kind
curl http://localhost:18150/
{"message":"Hello from a locally built image","version":"1.0 (rebuilt)","host":"hello-local-7b6dcbf9cd-thn44"}

Avoid the latest tag for this. latest silently switches the default pull policy to Always, which hides the problem locally and causes surprises later.

Step 8 — The Same Cluster with the kind CLI

Install kind. On macOS with Homebrew use brew install kind. Otherwise download the binary for your platform from the release page:

curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.33.0/kind-darwin-arm64
chmod +x ./kind
./kind version
kind v0.33.0 go1.26.7 darwin/arm64

Describe the cluster in kind-lab.yaml. This is the file you commit to Git:

kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: lab
nodes:
- role: control-plane
extraPortMappings:
- containerPort: 30080 # a NodePort inside the cluster
hostPort: 18190 # the port on your machine
protocol: TCP
- role: worker
labels:
tier: frontend
- role: worker
labels:
tier: backend
- role: worker
labels:
tier: backend

Three things are worth noting:

  1. extraPortMappings publishes a port of the node container on your machine. Plain kind has no LoadBalancer, so the usual pattern is a NodePort Service on a fixed port plus this mapping.
  2. labels sets node labels at creation time. You will use these later for node selectors and affinity.
  3. Add image: kindest/node:v1.36.4@sha256:... to every node if you want to pin the version.

Create the cluster:

kind create cluster --config kind-lab.yaml
✓ Installing CNI 🔌
✓ Installing StorageClass 💾
✓ Joining worker nodes 🚜
✓ Waiting ≤ 2m0s for control-plane = Ready ⏳
• Ready after 7s 💚
Set kubectl context to "kind-lab"

kind switched your current context to kind-lab. Check the labels:

kubectl get nodes -L tier
NAME STATUS ROLES AGE VERSION TIER
lab-control-plane Ready control-plane 46s v1.37.0
lab-worker Ready <none> 32s v1.37.0 frontend
lab-worker2 Ready <none> 32s v1.37.0 backend
lab-worker3 Ready <none> 32s v1.37.0 backend

Step 9 — Load the Image and Use the Port Mapping

Plain kind cannot see your Docker images, so load the image first:

kind load docker-image hello-local:1.0 --name lab
Image: "hello-local:1.0" with ID "sha256:..." not yet present on node "lab-control-plane", loading...
Image: "hello-local:1.0" with ID "sha256:..." not yet present on node "lab-worker", loading...

Then expose the Deployment on the NodePort that the config file mapped:

kubectl create deployment hello-local --image=hello-local:1.0
kubectl create service nodeport hello-local --tcp=80:8080 --node-port=30080
kubectl rollout status deploy/hello-local
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:18190/
200

The request entered the control-plane node container on port 30080, and kube-proxy forwarded it to a Pod on one of the workers.

After a rebuild under the same tag, run kind load docker-image again. The stale-image trap from Step 6 applies here too.

Step 10 — Clean Up

Remove the demo from the Docker Desktop cluster:

kubectl config use-context docker-desktop
kubectl delete namespace arch-kind

Delete the kind CLI cluster. This removes the node containers and its context:

kind delete cluster --name lab

In Docker Desktop, Reset cluster in the Kubernetes view recreates the cluster from scratch, and it is also how you move to a newer Kubernetes version. Docker Desktop does not upgrade an existing cluster automatically. Stop removes the Kubernetes containers.

Summary

  1. kind runs every Kubernetes node as a Docker container with its own containerd and kubelet, which makes real multi-node clusters cheap on a laptop.
  2. Docker Desktop's kind provisioner creates a multi-node cluster from the UI. It requires the containerd image store, and its context is docker-desktop.
  3. The kind CLI builds the same cluster from a versioned config file, with extraPortMappings, node labels and pinned node images.
  4. Docker Desktop feeds locally built images to the nodes through a registry mirror. Plain kind needs kind load docker-image.
  5. Nodes cache images. Rebuilding under the same tag with IfNotPresent keeps the old code running. Use a new tag per build, or imagePullPolicy: Always while iterating.
  6. On Docker Desktop, LoadBalancer Services are reachable on localhost:<port>. On plain kind use a NodePort plus a port mapping, or install cloud-provider-kind.

With a multi-node cluster in place, the next articles move from the cluster to the workload, starting with the kubeconfig file that connects kubectl to all of these clusters.