The tools are installed (see the workstation chapter) — now it's time to spin up a real Kubernetes cluster right on your laptop. In a couple of minutes you'll have a working dev cluster, into which we'll later roll out the myapp service (an HTTP API on Python 3.12 + FastAPI, port 8080, depends on PostgreSQL). We'll do this with k3d — and first let's figure out what that even is.

What k3d is and why it's fast and lightweight

Let's start with the terms, so we don't get confused by similar-sounding names.

  • k3s is a full-featured but minimalist Kubernetes distribution from Rancher Labs. The entire control plane (the components that manage the cluster: the API server, the scheduler, the controllers) is packaged into a single binary, and external dependencies are kept to a minimum — all you need is a modern Linux kernel and mounted cgroups.
  • k3d is, by the official definition, a "lightweight wrapper to run k3s in Docker". So k3d itself isn't Kubernetes; it runs the nodes of a k3s cluster as ordinary Docker containers and manages them.

Where does the lightness come from? Regular Kubernetes stores cluster state in etcd — a distributed key-value database. By default, instead of etcd, k3s uses an embedded SQLite — essentially just a regular file. The k3s documentation puts it plainly: "SQLite is the default storage and will be used if no other is configured." You don't have to stand up and maintain an etcd cluster, so startup is fast and memory usage is modest.

How fast? According to a single 2024 benchmark (all the tools on the Docker driver), k3d starts in a few seconds and uses noticeably less memory than kind or minikube. The exact numbers depend heavily on your hardware, your OS (Apple Silicon, WSL2), and the versions involved, so treat them as a rough guide rather than a guarantee. The key practical takeaway: a k3d cluster spins up and tears down so fast and so cheaply that recreating it from scratch is a routine operation, not a scary one.

One thing to keep in mind for later: SQLite doesn't work with multiple server nodes. If you want to simulate a highly available (HA) control plane made up of several servers, k3s will automatically switch to embedded etcd. For a single local cluster this doesn't matter, but it's useful to know.

When to use kind or minikube instead of k3d

k3d is a great default for day-to-day development, but it's not the only option. Briefly, what each is for:

ToolWhat it's for
k3dSpeed and lightness. Ideal for the inner dev loop (see chapter 1) and budget CI, where it's important to spin a cluster up and down quickly.
kindParity with prod. kind (Kubernetes IN Docker) runs the same upstream binaries as "big" Kubernetes, so it's the closest to prod; kind is exactly what the Kubernetes project itself uses for its own CI and conformance checks. Reach for kind when you need strict parity or you're testing the subtle internals of K8s.
minikubeLearning and realistic simulation. It gives you OS-level VM isolation and a rich set of addons, and it's handy when you're getting to grips with Kubernetes.

An important caveat about k3d: since it's k3s and not full upstream Kubernetes, certain edge cases and advanced features/policies may behave differently. Most applications (including our myapp) won't notice the difference, but for strict policy parity (for example, audit logging) you'll need explicit extra configuration. If your goal is to reproduce prod as faithfully as possible, see the chapter on getting closer to prod.

k3d cluster create: the main flags

The final command — run it once and use it from here on.

This command immediately spins up the dev cluster with a built-in registry and a forwarded port — everything you need for chapters 6–11. Once you've run it, you won't have to recreate the cluster as you work through the article:

1k3d cluster create dev \
2  --servers 1 --agents 2 \
3  --registry-create k3d-registry.localhost:5000 \
4  -p "8081:80@loadbalancer"

Below we walk through the flags step by step — but the working command we carry through the whole article is exactly this one. All the other examples in this chapter are shown to explain individual flags.

The simplest command creates a cluster with a default name:

1k3d cluster create mycluster

But we'll create our working dev cluster deliberately from the start. Here are the key flags from the official reference:

FlagWhat it does
--servers, -sHow many server nodes (control plane) to create
--agents, -aHow many agent nodes (workers) to create
--port, -pForward a port from the nodes out to the host
--api-portPin the port of the Kubernetes API server
--registry-createCreate a built-in registry and connect it to the cluster
--registry-useConnect an already existing k3d registry
--image, -iSet the k3s image (i.e. the Kubernetes version)
--k3s-argPass extra arguments to k3s itself
--volume, -vMount volumes into the nodes
--waitWait for the server nodes to be ready (enabled by default)

Let's create dev with one control plane and two workers:

1k3d cluster create dev --servers 1 --agents 2

If you need a specific Kubernetes version for parity with prod, set it via the k3s image:

1k3d cluster create dev --image rancher/k3s:v1.31.5-k3s1

(the version number here is just an example; substitute the one that matches your prod.)

kubectl contexts: where we are now and how to switch

kubectl — the Kubernetes client — needs to know which cluster to talk to. That's what a context is for. According to the Kubernetes documentation, a context is a triple: a cluster, a user, and a namespace (a logical division inside the cluster).

When k3d creates a cluster, it automatically adds a context named k3d-<cluster-name> and switches kubectl over to it. So for our dev the context is called k3d-dev, not just dev — that's an easy thing to trip over. And if you create a cluster with no name at all, it will be called k3s-default with the context k3d-k3s-default.

Useful commands:

1kubectl config current-context        # where we are now
2kubectl config get-contexts           # list of contexts (* = current)
3kubectl config use-context k3d-dev    # switch to our cluster
4kubectl config view --minify          # show config for the current context only

If for some reason the context didn't switch on its own, you can merge the kubeconfig manually and switch right away:

1k3d kubeconfig merge dev --kubeconfig-switch-context

Get into the habit of checking current-context before running dangerous commands — it saves you from an accidental kubectl delete in the wrong cluster.

The built-in image registry in k3d

To run myapp in the cluster, its Docker image (we'll build it in the containerization chapter) has to be stored somewhere the cluster can pull it from. Pushing images through a public registry on every iteration is slow and inconvenient. k3d can stand up its own local registry right next to the cluster.

Our end-to-end registry is called k3d-registry.localhost:5000 — that's the name we use across all chapters of the article. The simplest path is to create it together with the cluster (registries documentation):

1k3d cluster create dev --registry-create k3d-registry.localhost:5000

Here k3d-registry.localhost is the registry name, and 5000 is the port that gets forwarded to the host (0.0.0.0:5000). You can also create the registry separately — then it survives a cluster recreation and can be connected to different clusters:

1# create a standalone registry on port 5000
2k3d registry create registry.localhost --port 5000
3
4# connect it when creating the cluster (note the k3d- prefix!)
5k3d cluster create dev --registry-use k3d-registry.localhost:5000

An important detail: k3d prepends the k3d- prefix to the registry name. So even though we specified registry.localhost in k3d registry create, the full name k3d-registry.localhost:5000 is what appears in --registry-use (and in the image: fields of your manifests) — otherwise the connection won't find the registry. The port 5000 here isn't magic: you can pick any free one, the key thing is to use the same value everywhere.

From there the scheme is this: from your local machine you push the image to the forwarded port localhost:5000, and containerd inside the cluster pulls it from there (k3d generates the necessary registries.yaml itself):

1docker tag myapp:dev localhost:5000/myapp:dev
2docker push localhost:5000/myapp:dev

On Windows and macOS, resolving the registry name by container name sometimes doesn't work — in that case add a line like 127.0.0.1 k3d-registry.localhost to your hosts file, and pushing from the host is always more reliable through localhost:5000. In practice, if you use Tilt, it takes all this image build-and-push hassle off your hands.

Forwarding ports to the outside (--port)

The cluster lives inside Docker, and by default its internal ports aren't visible from the host. To reach myapp from a browser, you need to forward the port with the --port flag at cluster-creation time.

The recommended way (documentation on exposing services) is to forward the port through the serverlb (the built-in load balancer/proxy in front of the server nodes) using the @loadbalancer filter. By default k3s installs Traefik as the ingress controller, so this kind of forwarding is the natural path for entry via Ingress (details in the networking chapter):

1k3d cluster create dev \
2  --api-port 6550 \
3  -p "8081:80@loadbalancer" \
4  --agents 2

Here port 80 inside the cluster (where Traefik listens) is forwarded to 8081 on the host. Once the service is rolled out, you'll open http://localhost:8081.

The format of the --port value is: [HOST:][HOST_PORT:]CONTAINER_PORT[/PROTOCOL][@NODE-FILTER].

An alternative is to forward the port straight to a NodePort on one of the agents (a NodePort is Kubernetes' way of exposing a service on a fixed port of a node):

1k3d cluster create dev -p "8082:30080@agent:0"

The main gotcha: port forwards are fixed at the moment the cluster is created. You can't add a new port on the fly — you'll have to recreate the cluster. So it's best to plan ahead for the ports you'll need (in our final command above, port 8081 is already forwarded). Luckily, recreation in k3d is cheap — more on that below.

A few different ports show up in this article — to avoid confusion, keep this cheat sheet handy:

PortWhere it's usedChapter
8080the myapp application port inside the containerchapters 6–8
80the Service (ClusterIP) port in front of myappchapter 7
8081external entry via Ingress/loadbalancer on the hostchapters 5, 11
8000local docker run -p 8000:8080 to test the imagechapter 6
5000the k3d built-in registry (k3d-registry.localhost:5000)chapters 5, 6

Checking, deleting, and recreating the cluster

After k3d cluster create, let's make sure the cluster is alive. The two minimum commands:

1kubectl get nodes        # nodes should be in Ready status
2kubectl cluster-info     # the API server and core services are reachable

For our dev --servers 1 --agents 2, in the output of kubectl get nodes you'll see three nodes in the Ready state (one server node and two agent nodes). If that's the case — the cluster is ready to accept myapp.

Listing and deleting clusters is done with k3d commands:

1k3d cluster list             # all clusters on the machine
2k3d cluster delete dev       # delete the dev cluster (node containers and network)
3k3d cluster delete --all     # delete absolutely all clusters

k3d cluster delete (reference) removes the node containers and the cluster's Docker network. A note on the registry: the one created together with the cluster (--registry-create) is deleted along with it, while one created separately (k3d registry create) lives on its own — you'll have to delete it by hand.

Since startup is fast, the typical move when things get into a "dirty" state or you change the port configuration is to simply recreate the cluster from scratch:

1k3d cluster delete dev && k3d cluster create dev --agents 2 -p "8081:80@loadbalancer"

This is one of the main conveniences of k3d: a clean environment in a single line. In the next chapter we'll package myapp into a Docker image so there's something to roll out into this cluster.

Sources

Want a smooth local k3d setup for your team?
Need help setting up a fast local Kubernetes cluster with k3d — registries, port-forwards, and a clean dev workflow? I can help you get it right.