Sign in
← Kubernetes locally
8 min · easy · Lesson 4 of 8

k3d: k3s in containers

The fastest local cluster you can get, what comes bundled with k3s out of the box, and why it's worth watching the project's release pace.

Why

k3d is a wrapper that runs k3s inside Docker containers. A two- or three-node cluster comes up on a laptop in about thirty seconds and goes away with one command. To understand the tool, you first need to understand k3s.

Concepts

k3s is a lightweight Kubernetes distribution. Same API, same objects, same manifests, but built into a single binary with a trimmed set of internal components. Out of the box it ships with an ingress controller, a load balancer for Service of type LoadBalancer, and a storage provisioner. k3s itself targets Linux and installs directly on the machine.

k3d takes that binary and packs the nodes into containers. The result is the same idea as kind, just with a different distribution inside. That is where the speed comes from: in the official minikube time-to-k8s benchmark (v1.36.0, linux/amd64), k3d v5.8.3 brings up a cluster in 19.9 seconds, against 33.0 for kind v0.29.0 and 50.8 for minikube.

k3s has its own terminology. Server is what regular Kubernetes calls the control plane. Agent is a worker node. In front of the server nodes, k3d puts another container, the load balancer (k3d-<name>-serverlb). Requests to the API and every mapped port go through it.

Version and release pace

The current k3d version is v5.9.0, released June 2, 2026. By default it ships Kubernetes v1.35.5, k3s image v1.35.5-k3s1. It requires Docker 20.10.5 or newer.

To be blunt: this project has the slowest release pace among the popular local-cluster tools. Almost 16 months passed between v5.8.3 (February 2025) and v5.9.0 (June 2026). The last commit in the repository is dated June 12, 2026.

That doesn't mean the project is dead: the release shipped, commits keep coming, the tool works. But the pace is noticeably slower than kind's, where releases land every few months. For local learning, a pause of almost a year and a half changes almost nothing. For something that lives for years and needs quick updates for new Kubernetes versions, the difference between the two projects already matters.

Installation

macOS and Linux with Homebrew:

brew install k3d

Linux and WSL2 without Homebrew, the official script with a pinned version:

curl -s -o install-k3d.sh https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh
less install-k3d.sh
TAG=v5.9.0 bash install-k3d.sh

Without TAG the script installs the latest release. Windows without WSL2: choco install k3d. To check that everything is in place:

k3d version
k3d version v5.9.0
k3s version v1.35.5-k3s1 (default)

How it works

Cluster lifecycle

k3d cluster create lab --servers 1 --agents 2 -p "8081:80@loadbalancer" --api-port 6550
k3d cluster create lab --image rancher/k3s:v1.36.4-k3s1
k3d cluster list
k3d cluster delete lab

--servers 1 --agents 2 gives a three-node cluster: one server and two agents. --api-port 6550 publishes the API server on host port 6550. Without it, k3d picks a random free port.

The Kubernetes version isn't pinned by a separate flag, but by the k3s image: --image rancher/k3s:v1.36.4-k3s1. The tag format is the Kubernetes version plus the k3s revision suffix. In kubectl get nodes output, the same version shows up as v1.36.4+k3s1.

kubeconfig

By default, k3d cluster create appends the new cluster to your ~/.kube/config and immediately switches the current context to it. If a work cluster lives there, kubectl now points at the lab cluster instead. To keep the lab's kubeconfig separate (see the kubectl lesson), create the cluster with --kubeconfig-update-default=false and save the config to a file:

k3d kubeconfig get lab > lab-kubeconfig
export KUBECONFIG="$PWD/lab-kubeconfig"
kubectl config current-context
k3d-lab

k3d always names the context k3d-<cluster name>.

Exposing ports

k3d has two ways to expose a port to the host, and it pays to keep them apart. Both are written with the -p flag and differ by the node filter after the @.

The first goes through the load balancer:

-p "8081:80@loadbalancer"

Port 8081 on the host goes to port 80 on the server nodes. Traefik (the k3s ingress controller) listens there, so this is what you need for Ingress and for Service of type LoadBalancer.

The second goes to a specific node, for NodePort:

-p "8082:30080@agent:0"

Here agent:0 is the first worker node, and 30080 is the NodePort on it. Port 8082 is still published on the serverlb container: in k3d v5 every mapped port goes through it, and the node filter tells the balancer which node and port to send the traffic to. You can check what maps where like this:

docker port k3d-lab-serverlb
FlagWhere the port goesWhat it's for
-p "8081:80@loadbalancer"port 80 on the server nodes, Traefik listens thereIngress and Service of type LoadBalancer
-p "8082:30080@agent:0"port 30080 on the first worker nodeNodePort

The outside port and the Service's nodePort have to match. If the Service got a different nodePort, the request reaches the node, but nothing is waiting for it there.

The temptation to map the whole NodePort range at once is something the docs allow, but they warn about the cost: Docker spins up a separate proxy process for every mapped port. Across the 30000-32767 range that's nearly three thousand processes, and the machine can stall or hang for a long while.

What comes out of the box

Along with k3s, the cluster immediately gets:

The last one makes life noticeably easier. In kind and minikube, metrics-server is installed separately; in k3d it's already there.

Anything extra is disabled through k3s flags, which k3d passes along via --k3s-arg:

k3d cluster create --agents 1 --k3s-arg "--disable=traefik@server:*" --k3s-arg "--disable=servicelb@server:*"

The value format is ARG@NODEFILTER. If the cluster has more than one node, k3d refuses to create it without a node filter, with the error lacks a node filter. So always write the @server:* suffix. You disable Traefik when another ingress controller takes its place, and servicelb when you need MetalLB.

Common mistakes

The cluster ended up in the work kubeconfig. You created the cluster without --kubeconfig-update-default=false, and the current context in ~/.kube/config switched to k3d-<name>. Check kubectl config current-context before any command that changes something.

The port is mapped to the wrong place. -p "8082:80@agent:0" with a Service of type NodePort, or -p "8081:30080@loadbalancer" with Ingress: the container has the port, but there's no response. Compare the output of docker port k3d-<name>-serverlb with the Service's ports (kubectl get svc).

ZFS. On a ZFS filesystem, a cluster with more than one server node won't come up: k3s doesn't support ZFS.

BTRFS. Needs the device mapper passed into the node: -v /dev/mapper:/dev/mapper.

NodeHasDiskPressure. Nodes are containers, and they share disk with the host. When the host runs low on space, kubelet sees the shortage and starts evicting Pods. The cause sits outside the cluster, so you won't find it inside.

Reaching the host from Pods. To reach services running on the host machine, use the name host.k3d.internal. The address localhost inside a Pod points at the Pod itself.

In short

What you'll need for the tasks

Done reading?

Mark the lesson as read to move your track progress forward.

Go through it with an AI agent

The agent explains anything unclear in plain words, gives you 2 labs for this lesson and drills you on interview questions. $49 one-time, lifetime access.

Get access for $49

Tell the agent: use MCP izi, give me lesson k8s-local-04-k3d