Running Kubernetes locally used to mean a heavy virtual machine and a long wait. kind and k3d changed that by running clusters as containers, which start in seconds and cost almost nothing when idle. This guide sets up a working local cluster with ingress, local image loading, and a development loop that does not involve pushing to a registry on every change.
๐ Table of Contents
kind or k3d: Which to Choose
Both run Kubernetes inside Docker. They differ in what they run and what they optimise for.
| kind | k3d | |
|---|---|---|
| Kubernetes | Upstream, unmodified | k3s โ lightweight, some components swapped |
| Start time | Fast | Faster |
| Memory use | Higher | Lower |
| Best for | Testing against real upstream behaviour, CI | Fast iteration, constrained laptops |
Choose kind if you need behaviour identical to production upstream Kubernetes, particularly for testing operators or admission controllers. Choose k3d if you want the fastest, lightest loop for application development. Both are excellent; this guide covers each.
Prerequisites
# Docker must be running
docker version
# kubectl
brew install kubectl # macOS
# or: curl -LO "https://dl.k8s.io/release/$(curl -Ls https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
# kind
brew install kind
# k3d
brew install k3d
Option A: kind
A single-node cluster is one command, but a configuration file is worth writing immediately because ingress requires port mappings that cannot be added later without recreating the cluster.
# kind-config.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
kubeadmConfigPatches:
- |
kind: InitConfiguration
nodeRegistration:
kubeletExtraArgs:
node-labels: "ingress-ready=true"
extraPortMappings:
- containerPort: 80
hostPort: 80
protocol: TCP
- containerPort: 443
hostPort: 443
protocol: TCP
- role: worker
- role: worker
kind create cluster --name dev --config kind-config.yaml
# kubectl context is switched automatically
kubectl cluster-info --context kind-dev
kubectl get nodes
The extraPortMappings block forwards host ports 80 and 443 into the cluster node, which is what makes http://localhost reach your ingress controller. Add it up front โ retrofitting means deleting and recreating the cluster.
Ingress on kind
kubectl apply -f https://raw.githubusercontent.com/kubernetes-sigs/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
kubectl wait --namespace ingress-nginx \
--for=condition=ready pod \
--selector=app.kubernetes.io/component=controller \
--timeout=90s
Option B: k3d
k3d handles port mapping and ingress with flags, so setup is shorter. k3s includes Traefik as its ingress controller by default.
k3d cluster create dev \
--agents 2 \
--port "80:80@loadbalancer" \
--port "443:443@loadbalancer"
kubectl get nodes
To use ingress-nginx instead of Traefik, disable the bundled one at creation time.
k3d cluster create dev \
--agents 2 \
--port "80:80@loadbalancer" \
--k3s-arg "--disable=traefik@server:0"
Deploying an Application
# app.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 2
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- name: web
image: my-app:dev
imagePullPolicy: IfNotPresent
ports:
- containerPort: 3000
readinessProbe:
httpGet:
path: /healthz
port: 3000
initialDelaySeconds: 2
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
memory: 512Mi
---
apiVersion: v1
kind: Service
metadata:
name: web
spec:
selector:
app: web
ports:
- port: 80
targetPort: 3000
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: web
spec:
ingressClassName: nginx
rules:
- host: app.localhost
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: web
port:
number: 80
imagePullPolicy: IfNotPresent is essential for local development. The default Always makes Kubernetes try to pull my-app:dev from a registry, which does not exist, and the pod fails with ErrImagePull.
Loading Local Images โ The Step Everyone Misses
Your cluster runs in its own containers with its own image store. An image built on your host is invisible to it until you load it explicitly.
# Build normally
docker build -t my-app:dev .
# kind
kind load docker-image my-app:dev --name dev
# k3d
k3d image import my-app:dev -c dev
Then apply and check.
kubectl apply -f app.yaml
kubectl get pods -w
curl -H "Host: app.localhost" http://localhost/
If pods sit in ImagePullBackOff, you almost certainly skipped the load step or left imagePullPolicy at its default.
A Fast Inner Loop
Rebuilding, loading, and redeploying by hand for every change is too slow to sustain. Two approaches fix it.
Tilt or Skaffold watch your source, rebuild, load the image, and update the deployment automatically.
# Tiltfile
docker_build('my-app', '.')
k8s_yaml('app.yaml')
k8s_resource('web', port_forwards='3000:3000')
tilt up
Port-forwarding is enough when you only need to reach a service without ingress.
kubectl port-forward svc/web 3000:80
# now http://localhost:3000
Persistent Volumes
Both tools ship a default storage class, so a PersistentVolumeClaim works without configuration.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: data
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 1Gi
To keep data on your host so it survives cluster deletion, mount a host directory at creation time.
# kind-config.yaml
nodes:
- role: control-plane
extraMounts:
- hostPath: /Users/me/k8s-data
containerPath: /data
Debugging
# Why is a pod not starting? Events are at the bottom.
kubectl describe pod <pod-name>
# Logs, including from a crashed previous container
kubectl logs <pod-name>
kubectl logs <pod-name> --previous
# Shell into a running container
kubectl exec -it <pod-name> -- sh
# Debug a container with no shell using an ephemeral container
kubectl debug -it <pod-name> --image=busybox --target=web
# Cluster-wide recent events, newest last
kubectl get events --sort-by=.metadata.creationTimestamp
kubectl describe pod is the first command to run for almost any problem. The Events section at the bottom states plainly whether the image failed to pull, the node lacked resources, or a probe failed.
Cleaning Up
# kind
kind delete cluster --name dev
# k3d
k3d cluster delete dev
# k3d can also stop and restart without losing state
k3d cluster stop dev
k3d cluster start dev
Common Mistakes
Forgetting to load the image. The cluster cannot see your host’s Docker images. Load them explicitly.
Leaving imagePullPolicy at the default. With a :latest tag the default is Always, which fails for local-only images.
Adding port mappings after creation. They are fixed at cluster creation. Plan ingress ports before you create.
Omitting resource requests. Without them the scheduler cannot reason about capacity, and local clusters overcommit until pods are evicted.
Assuming local equals production. Local clusters have no real load balancer, no cloud storage classes, and no network policy enforcement by default. Treat them as a development tool, not a production simulator.
Conclusion
A productive local Kubernetes setup needs four things right: create the cluster with the port mappings you will need for ingress, load local images explicitly into the cluster, set imagePullPolicy: IfNotPresent so Kubernetes does not chase a registry, and automate the rebuild loop with Tilt or Skaffold. Use kind when you need upstream-identical behaviour and k3d when you want the lightest, fastest loop. Both let you delete and recreate a cluster in seconds, which is the real advantage โ a broken local cluster stops being a problem worth debugging.
๐ You might also like
๐ Share this article




โ๏ธ Leave a Comment