DEV Community

Metronom
Metronom

Posted on

k3d for Local Kubernetes: How We Cut Cluster Startup From 3 Minutes to Under 10 Seconds

k3d for local Kubernetes turned "reset your cluster" from a coffee-break chore into a one-line reflex. Our onboarding doc had a line I grew to hate: "if your local cluster is acting weird, delete it and start over (grab a coffee, this takes a few minutes)." On minikube and Docker Desktop's built-in Kubernetes, that was literally minutes of VM boot and a laptop that sounded like it was preparing for liftoff. Here's the switch, the command, and the gotchas.

What broke

Recreating a cluster was expensive, so people avoided it. They nursed broken clusters with hacks instead of resetting to a known-good state, which manufactured "works on my machine" bugs that cost even more time. Multiply a few resets a day across eight engineers: real hours burned on a thing that should be instant.

I wanted local Kubernetes disposable — cheap enough that recreating it is boring. There's a solid walkthrough of standing up a real local cluster in a couple of minutes at this k3d cluster writeup.

What k3d actually is

The naming trips everyone up:

  • k3s — Rancher's minimalist Kubernetes distro that packs the whole control plane (API server, scheduler, controllers) into one binary.
  • k3d — a lightweight wrapper that runs k3s nodes as ordinary Docker containers. "Little helper to run CNCF's k3s in Docker" (the k3d project). k3d isn't Kubernetes; it orchestrates k3s-in-Docker.

The lightness has a concrete source. Standard Kubernetes stores cluster state in etcd, a distributed key-value store you stand up and babysit. k3s defaults to an embedded SQLite file instead — no etcd cluster to maintain, so startup is fast and memory modest (k3s datastore docs). For a single local cluster the trade-off is invisible; the payoff is that up and down are so cheap that recreating from scratch is routine, not scary. (SQLite can't back a multi-server cluster — for HA, k3s uses embedded etcd via --cluster-init.)

Why k3d over kind and minikube

Match each tool to a job:

  • k3d — speed and lightness; the daily inner loop and budget CI where you spin clusters up and down constantly.
  • kind — maximum prod parity; runs clusters as Docker "nodes," built to test Kubernetes itself, which is why the project uses it for its own CI (kind docs).
  • minikube — learning and realistic simulation, VM-level isolation, rich addons.

Two more names show up in every 2026 local-Kubernetes comparison: MicroK8s (Canonical's snap-based single package, great on Ubuntu) and k0s (another single-binary distro). Both solid; we skipped them because k3d's Docker-native, throwaway-cluster model fit our all-macOS, container-first workflow best. On Ubuntu or wanting a systemd service instead of containers, MicroK8s is worth a look.

Our bottleneck was iteration speed, not conformance testing. The honest caveat: because it's k3s and not full upstream Kubernetes, some advanced edge cases and policies (audit logging, for instance) behave differently and need explicit config. Our app never noticed; if yours needs byte-perfect parity, that's what kind is for. The k3d documentation is the reference I send people who ask which local Kubernetes to use.

The one command we standardized on

This line replaced our whole onboarding paragraph — one control-plane node, two workers, a built-in registry, and a forwarded ingress port in a single shot:

k3d cluster create dev \
  --servers 1 --agents 2 \
  --registry-create k3d-registry.localhost:5000 \
  -p "8081:80@loadbalancer"
Enter fullscreen mode Exit fullscreen mode

Flag by flag:

  • --servers 1 --agents 2 — one control plane, two workers, so kubectl get nodes shows three Ready nodes and you get real multi-node scheduling.
  • --registry-create k3d-registry.localhost:5000 — a built-in image registry next to the cluster, so you don't round-trip a public registry on every iteration.
  • -p "8081:80@loadbalancer" — forwards host 8081 to port 80 inside the cluster via k3d's built-in serverlb, which sits in front of Traefik (k3s's default ingress). App reachable at http://localhost:8081.

To match a prod version, pin the k3s image: k3d cluster create dev --image rancher/k3s:v1.31.5-k3s1.

The two gotchas that would have burned us

1. The context is named k3d-dev, not dev

When k3d creates a cluster it adds a kubectl context prefixed with k3d- and switches to it automatically. We put kubectl config current-context in everyone's shell prompt after one engineer nearly ran a destructive command against the wrong cluster:

$ kubectl config current-context
k3d-dev
Enter fullscreen mode Exit fullscreen mode

Checking context before anything dangerous is now muscle memory.

2. Port forwards are frozen at creation time

You cannot add a forwarded port to a running k3d cluster — you recreate it. Sounds painful until you remember recreation is cheap, which is the whole point. We plan ports up front (8081 ingress, 5000 registry) so we rarely need to.

Registry subtlety: k3d prepends k3d- to the registry name, so you push to the forwarded localhost:5000 from your machine and reference k3d-registry.localhost:5000 in manifests. On macOS we added 127.0.0.1 k3d-registry.localhost to /etc/hosts for reliable resolution:

docker tag myapp:dev localhost:5000/myapp:dev
docker push localhost:5000/myapp:dev
Enter fullscreen mode Exit fullscreen mode

The move that changed the workflow: disposable clusters

When a cluster gets weird, we don't debug the cluster — we delete and recreate:

k3d cluster delete dev && k3d cluster create dev --agents 2 -p "8081:80@loadbalancer"
Enter fullscreen mode Exit fullscreen mode

Clean environment in one line, in seconds. That killed an entire class of "my local Kubernetes is haunted" tickets. The behavior change is bigger than the stopwatch: when a reset is free, people reset before they debug, so they're always debugging their app against a known-good cluster instead of fighting accumulated cruft. Cheap recreation is the actual product here, not raw startup speed. Runbook note: a registry created with --registry-create is deleted with the cluster, so to survive a reset create it standalone with k3d registry create and attach via --registry-use. For our fast dev loop a fresh registry every reset is a feature. Health check is kubectl get nodes (expect three Ready) and kubectl cluster-info.

Before / after

Metric minikube / Docker Desktop K8s k3d
Fresh cluster startup minutes seconds
"Reset my cluster" friction coffee-break chore routine one-liner
Local image push loop public registry round-trip built-in localhost:5000
Idle laptop resource use heavy (VM) light (containers)
CI cluster spin-up cost slow, expensive runners fast, cheap
Onboarding to a working cluster multi-step doc one command

Guardrail

  • Confirm the context before every destructive command. k3d-dev vs a real cluster is one wrong kubectl delete away from a bad day. Put it in the prompt.
  • Decide port layout at creation. Forwards are immutable; plan ingress and registry ports once.
  • Recreate, don't nurse. A haunted cluster is cheaper to delete than to diagnose. Reset early and often.
  • k3s is not upstream Kubernetes. For conformance-sensitive tests (audit policy, admission edge cases), keep a kind job around.

What I'd do differently

Standardize the single cluster create command in the repo from day one, not after a month of everyone hand-rolling flags. We've since wired the same command into CI so every pipeline run gets a pristine ephemeral cluster, layered Tilt on top to take the image build-and-push loop off developers, and kept a kind-based job for the rare conformance-sensitive test where k3s parity isn't enough.

Bottom line: if "recreate the local cluster" is a dreaded chore, the tooling is the problem — k3d makes clusters disposable, and disposable clusters make the whole team debug their app instead of their environment.

Sources

Top comments (0)