kind vs k3d vs minikube for Local Development
Half the team uses minikube, a few use Docker Desktop's built-in Kubernetes, and the platform engineer who wrote the Helm charts uses kind — so "the chart works on my cluster" means three different things. Behaviour differs in storage classes, ingress controllers, load-balancer support and default Kubernetes versions, and each difference shows up as a bug report that nobody can reproduce. This page compares the three most common local distributions on the criteria that matter for a team and gives a way to decide, under local Kubernetes development.
All three are conformant, CNCF-certified Kubernetes. The differences are in packaging, defaults and resource use, not in the Kubernetes API itself — which is exactly why a team should pick one: the defaults are where the surprises come from.
Diagnostic
Before choosing, record what the team currently runs and what the charts assume. This script prints the distribution, version, default storage class and ingress class of the current context:
#!/usr/bin/env bash
set -euo pipefail
ctx="$(kubectl config current-context)"
echo "context: $ctx"
kubectl version --output=json | jq -r '"server: " + .serverVersion.gitVersion'
kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name}{" "}{.status.nodeInfo.containerRuntimeVersion}{"\n"}{end}'
kubectl get storageclass -o jsonpath='{range .items[*]}{.metadata.name}{" default="}{.metadata.annotations.storageclass\.kubernetes\.io/is-default-class}{"\n"}{end}'
kubectl get ingressclass -o name 2>/dev/null || echo "no ingress class"
Typical output collected from three developers shows the problem:
context: minikube server: v1.31.0 storage: standard ingress: nginx (addon)
context: kind-shop server: v1.30.4 storage: standard ingress: none
context: k3d-shop server: v1.30.4+k3s1 storage: local-path ingress: traefik
Three storage class names, two ingress controllers and a minor-version spread — a chart with storageClassName: standard hard-coded works on two of them.
Root cause
Each distribution optimises for a different use. kind (Kubernetes IN Docker) was built to test Kubernetes itself, so it is minimal, starts nodes as containers, and ships no ingress or load balancer. k3d packages k3s, a lightweight distribution for edge and IoT, which bundles Traefik, a service load balancer and a local-path storage provisioner, and uses less memory. minikube targets learning and single-developer use, supports many drivers (Docker, Podman, HyperKit, Hyper-V, QEMU), and exposes common components as add-ons. Charts written against one distribution tend to encode its defaults — a storage class name, an ingress class, an assumption that LoadBalancer services get an IP — and those assumptions break on the others.
The version dimension compounds this. Each distribution ships its own default Kubernetes version tied to its release: a developer who installed minikube a year ago and never upgraded may be two minor versions behind a colleague who installed kind last week. Kubernetes removes deprecated API versions on a published schedule, so a manifest using policy/v1beta1 PodDisruptionBudgets or an old autoscaling API applies on the older cluster and fails on the newer one. From the developer's point of view the chart "randomly" works for some people. The fix is the same for every distribution: pin the node image explicitly and keep it in step with production, rather than inheriting whatever the tool's release defaults to.
Finally, the distributions differ in how they fail under memory pressure. kind and k3d nodes are ordinary containers inside the Docker VM, so an over-committed cluster triggers the VM's OOM killer and nodes restart. minikube with a VM driver gets its own memory allocation and fails inside that VM instead. Neither is better in principle, but a team should know which failure it will see.
Resolution
- Measure the candidates on the team's machines. Startup time and idle memory are the numbers developers feel every day:
#!/usr/bin/env bash
set -euo pipefail
t=$(date +%s); kind create cluster --name bench --image kindest/node:v1.30.4 >/dev/null; echo "kind: $(( $(date +%s) - t ))s"
docker stats --no-stream --format '{{.Name}} {{.MemUsage}}' | grep bench
kind delete cluster --name bench >/dev/null
t=$(date +%s); k3d cluster create bench --image rancher/k3s:v1.30.4-k3s1 >/dev/null; echo "k3d: $(( $(date +%s) - t ))s"
docker stats --no-stream --format '{{.Name}} {{.MemUsage}}' | grep bench
k3d cluster delete bench >/dev/null
t=$(date +%s); minikube start -p bench --driver=docker --kubernetes-version=v1.30.4 >/dev/null; echo "minikube: $(( $(date +%s) - t ))s"
docker stats --no-stream --format '{{.Name}} {{.MemUsage}}' | grep bench
minikube delete -p bench >/dev/null
Weigh CI parity heavily. If CI already creates clusters with kind (the default in many GitHub Actions and Kubernetes projects), using kind locally means a chart that installs on a laptop installs in CI with the same defaults. That usually outweighs k3d's startup advantage.
Remove distribution-specific defaults from charts. Make storage and ingress classes configurable and set them in a local values file, so the chart works on any of the three:
persistence:
storageClassName: ""
ingress:
className: nginx
hosts:
- api.localhost
service:
type: ClusterIP
An empty storageClassName uses the cluster's default class, whatever its name. ClusterIP plus an ingress avoids depending on LoadBalancer support, which differs everywhere.
- Pin the choice in the repository — a config file for the chosen distribution and a lifecycle script — and add a doctor check that fails when the current context is not the team cluster.
Expected output
After standardising on kind with configurable charts, the same diagnostic run on every laptop and in CI prints identical defaults:
context: kind-shop server: v1.30.4 storage: standard (default) ingress: nginx
And helm install with values-local.yaml succeeds on all machines and in the CI job with the same manifest output, which helm template can confirm byte-for-byte.
Prevention
Lint charts for hard-coded classes. A CI grep for
storageClassName: [a-z]andingressClassName:outside values files catches regressions.Install charts in CI on the local distribution with the local values file, so a chart change that breaks laptops fails the pull request.
Pin node image versions in the cluster config, matching production's minor version, and update them together with production upgrades.
Platform caveats
Apple Silicon (ARM64): all three support arm64 nodes. minikube's QEMU and HyperKit drivers do not; use
--driver=dockeron M-series Macs.
macOS (Docker Desktop): the built-in Kubernetes toggle is a fourth option. It cannot be version-pinned per project and is hard to reset, which makes it a poor team standard even though it is convenient.
WSL2: kind and k3d run inside WSL2 against Docker Desktop or Docker Engine. minikube with the Hyper-V driver runs on the Windows side and is reached differently; prefer the Docker driver for consistency with the rest of the team.
Linux: k3d and kind both need raised inotify limits for clusters with many pods; minikube inside its VM manages its own.
Rollback
Switching distributions only changes the lifecycle script and the kube context; manifests and charts stay the same once class names are configurable:
#!/usr/bin/env bash
set -euo pipefail
kind delete cluster --name shop 2>/dev/null || true
k3d cluster create shop --image rancher/k3s:v1.30.4-k3s1
kubectl config use-context k3d-shop
Frequently Asked Questions
Which is fastest to start?
k3d usually starts fastest because k3s is a single small binary with fewer components. kind is close behind; minikube is slower with the Docker driver and much slower with VM drivers. Measure on your own laptops, since results vary with image caching.
Does k3d's bundled Traefik conflict with our nginx ingress?
It can, because both want ports 80 and 443. Create the cluster with --k3s-arg "--disable=traefik@server:0" and install the same ingress controller production uses.
Can kind simulate multiple nodes?
Yes. Add more entries under nodes in the config file; each node is a container. This is useful for testing pod anti-affinity, topology spread and draining, at the cost of more memory.
Should we use Docker Desktop's built-in Kubernetes instead?
It is fine for one person experimenting, but it cannot be version-pinned per project, shares the Docker VM's resources, and is awkward to reset. A scripted kind or k3d cluster is easier to support across a team.