Every developer's laptop has a local Kubernetes cluster. Most of them are abandoned within a fortnight because they eat RAM, fight Docker for network ports, and run background agents that cut battery life in half while you're presenting in front of a client.
k3d is different. It wraps k3s — the lightweight Kubernetes distribution from Rancher — inside Docker containers. The entire cluster runs as a set of Docker containers. When you're done, you delete those containers. Nothing persists. Nothing idles in the background consuming 4 GB you didn't allocate to it. The next morning you run one command and you have a clean cluster again in under 30 seconds.
Local Platform Engineering Series
- Running Local Kubernetes with k3d: Fast, Ephemeral, and Kind to Your Battery
- Gateway API in Practice: From Ingress Migration to Envoy Debugging
- Multi-Tenant Observability: LGTM at Platform Scale
- Network Control with Cilium and Kyverno: Policies That Actually Work
- Observing LLM Inference: The Metrics That Actually Matter
- AI Tool Gateways: Sandboxing Agent Access in Kubernetes
Why Most Local Kubernetes Setups Fail
The standard advice is to install minikube or Docker Desktop's built-in Kubernetes. Both are fine for getting started. Neither is fine for daily platform engineering work.
Minikube runs a VM. That VM has a fixed memory allocation you set once at creation time and cannot easily adjust. If you set it too low, workloads OOM. If you set it high, it's consuming that RAM whether you're using Kubernetes or not. The VM starts with your machine by default. It has a persistent disk image. Deleting a minikube cluster is not guaranteed to clean up everything it touched.
Docker Desktop's Kubernetes is worse for iteration. It shares the Docker daemon, which means its networking can interfere with containers you're running outside Kubernetes. You can't create multiple clusters. You can't script cluster creation in CI. The configuration lives in a GUI settings pane, not in a file you can commit.
k3d solves both problems by having no persistent state that isn't a Docker container. The entire cluster is containers. Containers are deleteable. That's the deal.
What k3d Actually Does
k3s is a CNCF-certified Kubernetes distribution that strips out the components most people don't need in a local or edge context — in-tree cloud provider integrations, most alpha features, embedded etcd replaced with SQLite by default. What's left is a valid Kubernetes cluster that starts in seconds and uses a fraction of the RAM.
k3d is a CLI wrapper that:
- Creates Docker containers running k3s server/agent binaries
- Sets up a network between them
- Configures a load balancer container (Traefik or NGINX) if you want one
- Merges the kubeconfig into your local
~/.kube/config - Tears all of it down with a single command
Nothing else runs on your system. No system daemon. No background agent. No persistent volume on the host unless you explicitly mount one.
Installation
brew install k3d kubectl helm
k3d version # ≥ v5.6.0brew install k3d kubectl helm
k3d version # ≥ v5.6.0Docker Desktop must be running. Allocate at least 8 GB RAM in Docker Desktop → Settings → Resources — not for k3d specifically, but because the LGTM stack later in this series needs it.
The Cluster Config
Don't use k3d cluster create with inline flags. Write a config file and commit it. You will recreate this cluster many times.
apiVersion: k3d.io/v1alpha5
kind: Simple
metadata:
name: platform-local
servers: 1
agents: 2
options:
k3s:
extraArgs:
- arg: "--disable=traefik"
nodeFilters:
- server:*
volumes:
- volume: /tmp/k3d-local:/var/lib/rancher/k3s/storage
nodeFilters:
- allapiVersion: k3d.io/v1alpha5
kind: Simple
metadata:
name: platform-local
servers: 1
agents: 2
options:
k3s:
extraArgs:
- arg: "--disable=traefik"
nodeFilters:
- server:*
volumes:
- volume: /tmp/k3d-local:/var/lib/rancher/k3s/storage
nodeFilters:
- allA few decisions worth explaining:
One server, two agents. The server runs the control plane (API server, scheduler, controller manager). The agents run your workloads. Two agents means you can test pod anti-affinity, node selectors, and topology spread constraints without faking it. More agents cost more memory; two is the right default.
Traefik disabled. k3s ships with Traefik as its default ingress controller. We're going to install Envoy Gateway and use the Gateway API instead — that's the next article in this series. Two ingress controllers will fight each other. Disable the one you didn't choose.
Volume mount. /tmp/k3d-local gives persistent storage to the cluster. Without it, every PersistentVolumeClaim survives only as long as the Docker container does. With it, Prometheus data and Loki indices survive cluster restarts.
Create the cluster:
k3d cluster create --config k3d-config.yaml
kubectl config use-context k3d-platform-local
kubectl get nodesk3d cluster create --config k3d-config.yaml
kubectl config use-context k3d-platform-local
kubectl get nodesThree nodes: one server, two agents, all Ready within 20–30 seconds.
Day-to-Day Workflow
Load local images into the cluster
When you build an image locally, it lives in Docker's image store. The k3d containers don't have access to it — they have their own containerd runtime. Import images explicitly:
docker build -t my-service:dev .
k3d image import my-service:dev --cluster platform-localdocker build -t my-service:dev .
k3d image import my-service:dev --cluster platform-localAfter import, reference my-service:dev in your manifests with imagePullPolicy: Never. The image is already there; no registry needed.
Resetting cleanly
When a cluster gets messy — conflicting CRDs, botched Helm releases, experiments gone sideways — don't debug it. Delete it and start fresh. That's the point.
k3d cluster delete platform-local
k3d cluster create --config k3d-config.yamlk3d cluster delete platform-local
k3d cluster create --config k3d-config.yamlThirty seconds. Clean state. This is not a practice drill for production; this is the actual workflow. The config is committed. Recreating is not a cost.
Multiple clusters
k3d handles multiple simultaneous clusters cleanly because each is an isolated set of Docker containers with separate networks. If you want to test cross-cluster federation or demonstrate multi-cluster service mesh, create two:
k3d cluster create --config k3d-config.yaml
k3d cluster create --config k3d-config-secondary.yaml
kubectx # switch between themk3d cluster create --config k3d-config.yaml
k3d cluster create --config k3d-config-secondary.yaml
kubectx # switch between themBoth clusters run concurrently. Both can be deleted independently.
k3d vs kind vs minikube
These tools are not interchangeable. Here's the honest trade-off:
| k3d | kind | minikube | |
|---|---|---|---|
| Startup time | ~20s | ~30s | ~90s (VM) |
| Memory overhead | Low (k3s) | Medium (kubeadm) | High (VM) |
| Multi-cluster | Easy | Easy | Awkward |
| Image loading | k3d image import | kind load docker-image | minikube image load |
| Default storage | local-path | local-path | hostPath |
| Upstream CI usage | No | Yes (k8s SIG) | No |
| Production similarity | k3s (not standard) | kubeadm (closer) | kubeadm (closer) |
Use kind if your primary goal is testing Kubernetes conformance or if you're contributing to upstream Kubernetes — kind is what Kubernetes' own CI uses. Use k3d if your primary goal is running workloads locally with minimal resource cost and fast iteration. Use minikube if you need GPU passthrough or specific VM networking features.
For platform engineering work — running Helm charts, testing operators, iterating on manifests — k3d is the right tool.
Teardown
k3d cluster delete platform-local
rm -rf /tmp/k3d-localk3d cluster delete platform-local
rm -rf /tmp/k3d-localNothing left on your system except the k3d binary and the config file you committed.
What's Next
A cluster with no routing layer is a cluster where everything needs a port-forward. The next article in this series sets up the Kubernetes Gateway API with Envoy — host-based routing across all services, the way a real platform would expose them, running entirely on this cluster.
Gateway API in Practice: From Ingress Migration to Envoy Debugging →