~/blog/local-kubernetes-with-kind

Running Local Kubernetes with kind: Ephemeral by Design, Production-Honest by Default

6 min read

Most engineers pick a local Kubernetes tool once, never revisit the decision, and then spend the rest of their time wondering why their local setup doesn't behave the way the docs say it should.

The answer is usually the tool. minikube runs a VM with a fixed memory allocation that you can't easily change and that starts automatically at boot. Docker Desktop's built-in Kubernetes shares a daemon with your other containers and can't be scripted. k3d is fast and lightweight — but it runs k3s, a non-standard distribution, which means when you reach for Cilium installation docs or the Envoy Gateway quickstart, you're following instructions written for a different runtime.

kind — Kubernetes IN Docker — runs real kubeadm inside Docker containers. It's what upstream Kubernetes CI uses. It's what Cilium's official installation guide targets. It's what the Envoy Gateway quickstart runs against. When something doesn't work, the debugging trail leads somewhere useful.

This is the first post in the Local Platform Engineering Series. We start here because everything that follows — Gateway API routing, LGTM observability, Cilium networking, LLM inference monitoring, AI tool gateways — runs on top of this cluster.


What kind Actually Does

kind creates Docker containers that run Kubernetes node processes. The control-plane container runs the API server, scheduler, and controller manager. Worker containers run kubelet and containerd. kubeadm wires them together — exactly the same way a real cluster is bootstrapped.

The cluster has no persistent state outside those containers. Delete the containers, the cluster is gone. Create them again, you have a clean cluster. The entire lifecycle is a single command in each direction.

This is deliberately not a VM. There's no hypervisor overhead, no fixed memory allocation that you set once and regret, no background daemon holding resources while you're not using Kubernetes. Docker manages the containers. When you're not running the cluster, the containers are stopped. When you delete the cluster, they're removed.

The tradeoff: kind uses kubeadm and runs a full etcd, API server, and controller stack. It's heavier than k3s-based tools. On a machine with 16 GB RAM, this is irrelevant. On a machine with 8 GB that's also running Chrome, IntelliJ, and a local database, you'll feel it. k3d is the right choice in that situation. For the stack we're building in this series — Cilium, the LGTM observability stack, Ollama — you need the headroom either way.


Installation

brew install kind kubectl helm
kind version  # ≥ 0.23.0
brew install kind kubectl helm
kind version  # ≥ 0.23.0

Docker Desktop must be running. Set at least 8 GB RAM in Docker Desktop → Settings → Resources before proceeding. The LGTM stack alone needs 4 GB; leave headroom for your cluster nodes and Ollama.


The Cluster Config

Write a config file. You'll recreate this cluster many times — inline flags don't commit, configs do.

kind-config.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: platform-local
nodes:
  - role: control-plane
  - role: worker
  - role: worker
kind-config.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: platform-local
nodes:
  - role: control-plane
  - role: worker
  - role: worker

Three nodes: one control-plane, two workers. Two workers matter when you're testing pod anti-affinity, topology spread constraints, or anything that cares about node distribution. One worker hides those issues.

kind ships with a local-path storage provisioner and kindnet as the default CNI. Both work out of the box. In part four, we'll replace kindnet with Cilium — but that's a separate step with its own config changes. Start with this.

kind create cluster --config kind-config.yaml
kubectl config use-context kind-platform-local
kubectl get nodes
kind create cluster --config kind-config.yaml
kubectl config use-context kind-platform-local
kubectl get nodes

Three nodes, all Ready, in around 30 seconds.


Day-to-Day Workflow

Load local images

kind nodes use their own containerd runtime, separate from Docker's image store. Build locally, then import:

docker build -t my-service:dev .
kind load docker-image my-service:dev --name platform-local
docker build -t my-service:dev .
kind load docker-image my-service:dev --name platform-local

Reference it in manifests with imagePullPolicy: Never. The image is already on the nodes; no registry pull happens.

Reset cleanly

When a cluster accumulates state you don't want — botched Helm releases, conflicting CRDs, half-applied manifests — delete and recreate:

kind delete cluster --name platform-local
kind create cluster --config kind-config.yaml
kind delete cluster --name platform-local
kind create cluster --config kind-config.yaml

Thirty seconds. This is the workflow, not a fallback. The config is committed; recreating costs nothing. Don't debug a cluttered cluster when a clean one takes half a minute.

Multiple clusters

kind runs multiple clusters concurrently, each as an isolated set of Docker containers:

kind create cluster --config kind-config.yaml
kind create cluster --name platform-local-secondary
kubectx  # switch between them
kind create cluster --config kind-config.yaml
kind create cluster --name platform-local-secondary
kubectx  # switch between them

Useful for testing cross-cluster federation, multi-cluster service discovery, or demonstrating failover behaviour without spinning up any cloud infrastructure.


kind vs k3d vs minikube

These tools solve different problems. Be deliberate about which one you're using and why:

kindk3dminikube
Startup time~30s~20s~90s (VM)
Memory overheadMedium (kubeadm)Low (k3s)High (VM)
Default CNIkindnet (NetworkPolicy ✓)flannel (NetworkPolicy ✗)kindnet
Upstream k8s CIYesNoNo
Cilium docs targetYesPartialNo
Gateway API quickstartYesNoNo
Production similaritykubeadmk3skubeadm
Image loadingkind load docker-imagek3d image importminikube image load

Use k3d when memory is genuinely constrained and you don't need Cilium or anything that specifically targets kind. Use minikube when you need GPU passthrough or VM-level networking isolation. Use kind for everything in this series — the ecosystem alignment alone is worth the 10-second startup difference.

The reason the Cilium guide and Envoy Gateway quickstart are written for kind is not coincidence. It's because kind's kubeadm setup is close enough to production that the same config works. k3s has enough differences in the control plane, CNI configuration, and node labelling that you hit edge cases the docs don't cover.


Teardown

kind delete cluster --name platform-local
kind delete cluster --name platform-local

Nothing left. No background daemon. No disk image. No lingering network interface. The Docker containers are removed, and Docker reclaims the resources.

If you're done for the day but want the cluster back tomorrow, stop it instead:

docker stop $(docker ps -q --filter "name=platform-local")
# resume
docker start $(docker ps -aq --filter "name=platform-local")
docker stop $(docker ps -q --filter "name=platform-local")
# resume
docker start $(docker ps -aq --filter "name=platform-local")

kind clusters survive Docker restarts this way. Everything in the cluster is preserved — deployments, ConfigMaps, PersistentVolumes. The cluster picks up where it left off.


What's Next

A cluster with no routing layer means port-forwarding everything. Part two sets up the Kubernetes Gateway API with Envoy Gateway — host-based routing across all services in the cluster, the same pattern a real platform would use.

Gateway API in Practice: From Ingress Migration to Envoy Debugging →