๐ŸŒ Detecting your locationโ€ฆ

How to Set Up Kubernetes Locally with kind and k3d in 2026: Complete Guide

โฑ๏ธ6 min read  ยท  1,128 words

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.

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.

MD Rafikul Islam

Written by

MD Rafikul Islam is a software developer and the editor of TechPulse. He writes about developer tooling, hardware, and the practical decisions that come up in day-to-day engineering work โ€” which laptop to buy, which framework to commit to, why a build broke at 2am. He tests the tools he writes about and says plainly when something is not worth the money. Corrections and corrections requests are welcome at rony.yf25@gmail.com.

โœ๏ธ Leave a Comment

Your email address will not be published. Required fields are marked *

๐ŸŒ Read in:๐Ÿ‡ฌ๐Ÿ‡ง English๐Ÿ‡ฉ๐Ÿ‡ช Deutsch๐Ÿ‡ง๐Ÿ‡ท Portuguรชs๐Ÿ‡ธ๐Ÿ‡ฆ ุงู„ุนุฑุจูŠุฉ๐Ÿ‡ฎ๐Ÿ‡ณ เคนเคฟเคจเฅเคฆเฅ€๐Ÿ‡ง๐Ÿ‡ฉ เฆฌเฆพเฆ‚เฆฒเฆพ