Skip to content

Running Kubernetes Locally

You do not need a data center to learn Kubernetes — tools like kind, minikube, and k3d give you a real, multi-node-capable cluster running entirely on your own machine.

Three tools cover almost all local Kubernetes development and learning:

  • kind (Kubernetes IN Docker) runs each cluster “node” as a Docker container. It is fast to create and destroy, which makes it a great fit for CI pipelines and for following along with a course like this one.
  • minikube runs a single- or multi-node cluster inside a VM or container driver of your choice, and ships with an ecosystem of addons (dashboard, ingress, metrics) enabled with one command each.
  • k3d runs k3s — a lightweight, certified Kubernetes distribution — inside Docker containers, similar in spirit to kind but built around k3s’s smaller footprint.

All three exist to make local development and learning fast and disposable. None of them is meant for production — production clusters are built with tools like kubeadm, or provisioned by a managed cloud service, on real or dedicated virtual machines with proper networking, storage, and high availability.

kindminikubek3d
What it runsKubernetes (kind.sigs.k8s.io) nodes as Docker/Podman containersFull Kubernetes (minikube.sigs.k8s.io), in a VM, container, or bare-metal driver of your choicek3s (k3d.io) — a certified, lightweight Kubernetes distribution — in Docker containers
Driver flexibilityDocker or Podman onlyMany drivers (docker, podman, and VM drivers like hyperkit/hyperv/kvm2)Docker only
Built-in addonsNone — this course installs Gateway API/CSI CRDs itself when a lesson needs themRich addon ecosystem (minikube addons enable dashboard/ingress/metrics-server)Minimal; k3s ships some components (Traefik, servicelb) already built in
Best fitCI pipelines, and following a course like this one where disposable, scriptable clusters matterA single, long-lived local cluster with a lot of one-command extrasA fast multi-cluster/multi-node setup built around k3s’s small footprint

Create a local cluster and take a look at it:

Terminal window
kind create cluster
kubectl cluster-info
kubectl get nodes

kubectl cluster-info confirms you can reach the API server and shows where core services are running; kubectl get nodes lists the node(s) in the cluster and their Ready status — the first thing to check whenever something seems wrong.

Pinning the Kubernetes version with a real node image digest

Section titled “Pinning the Kubernetes version with a real node image digest”

A bare kind create cluster uses whatever Kubernetes version that kind release defaults to — kind 0.33 defaults to 1.37.0. Pinning explicitly is what makes a course, a CI pipeline, or a bug report reproducible: kind’s own release notes publish an exact, digest-pinned node image per Kubernetes version, and the release notes are explicit that you must use the @sha256 digest form to guarantee you get the image actually built for that release:

Terminal window
kind create cluster --name foundations \
--image kindest/node:v1.37.0@sha256:a1ed56cfb0e7b93589bdf97c8cd566405a265939e3620fc4f5de89adff580ae5

That exact digest is quoted directly from the kind v0.33.0 release notes — a bare --image kindest/node:v1.37.0 tag (no digest) works today, but only the digest form guarantees you get the same bytes the kind maintainers actually tested and published for that release.

A real multi-node config, with extraPortMappings and node-labels

Section titled “A real multi-node config, with extraPortMappings and node-labels”

A single kind create cluster gives you one all-in-one node. A kind.x-k8s.io/v1alpha4 Cluster config gives you real multiple nodes, and lets you map a container port to your host and stamp extra kubelet node labels at creation time — both proven working below:

kind/kind-foundations.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
extraPortMappings:
- containerPort: 30080
hostPort: 8080
protocol: TCP
kubeadmConfigPatches:
- |
kind: InitConfiguration
nodeRegistration:
kubeletExtraArgs:
node-labels: "ingress-ready=true"
- role: worker
- role: worker
Terminal window
kind create cluster --name foundations --config kind-foundations.yaml

Real output creating exactly this config (1 control-plane + 2 workers):

Creating cluster "foundations" ...
✓ Ensuring node image (kindest/node:v1.37.0) 🖼️
✓ Preparing nodes 📦 📦 📦
✓ Writing configuration 📜
✓ Starting control-plane 🕹️
✓ Installing CNI 🔌
✓ Installing StorageClass 💾
✓ Joining worker nodes 🚜
Set kubectl context to "kind-foundations"

extraPortMappings on the control-plane node is what makes a NodePort Service (or an Ingress controller listening on a NodePort) reachable from your laptop’s browser at all — without it, the Service only exists inside the Docker network kind created. node-labels stamped through kubeadmConfigPatches lets you pre-label a node before any workload is scheduled, useful for things like an Ingress controller’s nodeSelector.

kind load docker-image: getting a locally built image onto every node

Section titled “kind load docker-image: getting a locally built image onto every node”

Nodes in a kind cluster cannot reach your host’s Docker image cache directly, and there is no registry involved for images you build locally — kind load docker-image copies an image straight into every node’s containerd image store. Proof, building a tiny image, loading it, then running a Pod from it with imagePullPolicy: Never (which forbids kubelet from trying to pull it from anywhere):

Terminal window
docker build -t local/tiny-hello:1.0 .
kind load docker-image local/tiny-hello:1.0 --name foundations

Real output:

Image: "local/tiny-hello:1.0" with ID "sha256:db50fb2b2b0258cb78bea24ee398f0a16cb4c9052bd7f714747c1039042ea1d0" not yet present on node "foundations-control-plane", loading...
Image: "local/tiny-hello:1.0" with ID "sha256:db50fb2b2b0258cb78bea24ee398f0a16cb4c9052bd7f714747c1039042ea1d0" not yet present on node "foundations-worker2", loading...
Image: "local/tiny-hello:1.0" with ID "sha256:db50fb2b2b0258cb78bea24ee398f0a16cb4c9052bd7f714747c1039042ea1d0" not yet present on node "foundations-worker", loading...
k8s/running-kubernetes-locally/tiny-pod.yaml
apiVersion: v1
kind: Pod
metadata:
name: tiny-hello
spec:
containers:
- name: tiny-hello
image: local/tiny-hello:1.0
imagePullPolicy: Never
$ kubectl apply -f tiny-pod.yaml
pod/tiny-hello created
$ kubectl get pod tiny-hello
NAME READY STATUS RESTARTS AGE
tiny-hello 1/1 Running 0 3s
$ kubectl logs tiny-hello
hello-from-tiny-image

imagePullPolicy: Never is what proves the image really was loaded onto the node — if it had not been, kubelet would report ErrImageNeverPull instead of ever reaching Running.

kubectl config: seeing every context you can reach

Section titled “kubectl config: seeing every context you can reach”

Every cluster you create — kind, a cloud cluster, anything — adds a context to the same kubeconfig, and kubectl config get-contexts is how you see all of them at once, with a * marking which one is current:

$ kubectl config get-contexts
CURRENT NAME CLUSTER AUTHINFO NAMESPACE
* kind-foundations kind-foundations kind-foundations
kind-verify-k8s kind-verify-k8s kind-verify-k8s
$ kubectl config current-context
kind-foundations

This is exactly why kubectl --context <name> (or kubectl config use-context) matters the instant you have more than one cluster around — every command otherwise runs against whichever context is currently starred, silently.

With a cluster up, run a Pod and reach it from your laptop:

Terminal window
kubectl run nginx --image=nginx
kubectl get pods
kubectl port-forward pod/nginx 8080:80

kubectl run is a quick imperative way to create a single Pod running one container, useful for smoke-testing an image. kubectl port-forward opens a tunnel from a local port on your machine to a port inside the Pod, so http://localhost:8080 reaches nginx running inside the cluster without needing a Service or Ingress at all — handy for the earliest stages of debugging or trying something out.

A namespace is a way to divide a single cluster into separate scopes for names, access, and resource limits. Every cluster ships with a few built in:

  • default — where objects land if you do not specify a namespace.
  • kube-system — reserved for components managed by the control plane itself, such as CoreDNS and kube-proxy’s own Pods; you generally look here, not touch here.
  • kube-public — readable by all users (including unauthenticated ones), used for cluster information that should be publicly visible.

List them, and create your own for a project:

Terminal window
kubectl get namespaces
kubectl create namespace my-app
flowchart TB
  laptop["Your laptop"] --> docker["Docker"]
  subgraph cluster["kind cluster"]
    cp["control-plane node (container)"]
    w1["worker node (container)"]
    w2["worker node (container)"]
  end
  docker --> cluster
A local kind cluster running entirely inside Docker on your laptop

When reviewing a generated kind config or setup script, check for:

  • A kindest/node tag with no @sha256 digest, presented as “pinned” — proven above, the kind release notes are explicit that only the digest form guarantees the exact tested image; a bare tag can be repointed.
  • kind load docker-image skipped entirely, with a Pod expected to run a locally built image straight from docker build — proven above, kind nodes cannot see your host’s Docker image cache; without loading the image (and imagePullPolicy: Never or IfNotPresent), kubelet will try to pull from a registry and fail for an image that was never pushed anywhere.
  • A single-node kind create cluster used to “prove” behavior that only appears with multiple nodes (spreading, node affinity, topology spread) — this course’s own multi-node config above is what such a lesson actually needs, not the one-line default.
  • kind recommended, or used, for anything described as a production deployment — proven in the comparison table above, none of kind, minikube, or k3d claim to be production-grade; all three exist specifically for local development, testing, and CI.
  • A generated multi-cluster script that never passes --context or --name explicitly, relying on whichever context happens to be current — proven above, kubectl config get-contexts can list several clusters at once with only one starred as current, and silently running against the wrong one is an easy, avoidable mistake.

🐛 Spot the bug

# A generated CI script, meant to test a locally built image
docker build -t myapp:test .
kind create cluster --name ci-test
kubectl apply -f pod.yaml # pod.yaml references image: myapp:test
Show the answer

This script builds the image and creates the cluster, but never runs kind load docker-image myapp:test --name ci-test in between. Proven above: a kind node’s containerd has no visibility into your host’s Docker image store at all — the two are separate systems. Without the load step, kubelet on the node will try to pull myapp:test from a registry (Docker Hub by default), and since it was never pushed anywhere, the Pod sits in ErrImageNeverPull or ImagePullBackOff depending on the configured imagePullPolicy — a confusing failure for a script that “should just work” because the image clearly exists locally. The fix is one line: kind load docker-image myapp:test --name ci-test between the build and the apply, proven above to make the image actually present on every node before anything tries to run it.

A kind/minikube/k3d cluster is not a simulation of Kubernetes — it is exactly the same kube-apiserver, etcd, scheduler, and kubelet as a production cluster, just running on nodes that happen to be containers (or a VM) on your laptop instead of real or cloud machines. Everything you prove here — a real kubectl explain, a real rollout, a real scheduling decision — is the real behavior, not an approximation; the only things missing are the things a single machine genuinely cannot provide (real multi-host networking at scale, cloud load balancers, durable storage across host failures). That is also exactly why loading an image matters: a kind node’s containerd is a real, isolated container runtime with no back channel to your host’s Docker — treat it as a separate machine, because for every purpose that matters here, it is one.

ChoiceBenefitCost
kindFastest create/destroy, built for CI and course-style disposable clusters (this course uses it throughout)Docker/Podman only — no VM isolation, no non-Docker driver
minikubeMost addons, most driver flexibility (VM or container)Heavier, slower to create/destroy than kind for a throwaway cluster
k3dVery fast, small footprint (wraps k3s)Some Kubernetes components are pre-substituted by k3s’s own choices (e.g. its own default ingress/load-balancer), which can surprise you if you expect vanilla upstream Kubernetes defaults
Pinning the node image with @sha256Reproducible across machines and CI runs — the exact tested bytes, proven aboveOne more value to keep updated when you deliberately want a newer Kubernetes version
kind load docker-image for every locally built imagePods can use an image that was never pushed to any registry (proven above)An extra step to remember in every build script; skip it and you get a real, confusing pull failure
  • “A kind/minikube/k3d cluster only approximates real Kubernetes behavior.” Proven above in the Mental Model: it runs the same real API server, etcd, scheduler, and kubelet as any other cluster — the proofs throughout this whole course run on exactly this kind of cluster.
  • “docker build makes an image immediately available to a Pod in a kind cluster.” Proven above with the real ErrImageNeverPull-style failure: a kind node’s containerd cannot see your host’s Docker image cache at all; kind load docker-image is required first.
  • “A bare kindest/node:v1.37.0 tag is exactly reproducible.” Proven above: kind’s own release notes state you must use the @sha256 digest to guarantee the exact image that release tested and published — a tag alone is not that guarantee.
  • “Whichever kind cluster you created most recently is the one your kubectl commands are running against.” Proven above with kubectl config get-contexts: the current context is whichever one is starred, not whichever is newest — always worth checking explicitly when more than one cluster exists.
What are kind, minikube, and k3d primarily used for?
Which command tells you whether your cluster nodes are healthy and ready?
What is the kube-system namespace used for?
Which command creates a new namespace of your own?
This is real kind load docker-image output. Why is this step necessary before a Pod can use a locally built image?
Image: "local/tiny-hello:1.0" with ID "sha256:db50fb..." not yet present on node "foundations-worker", loading...
Why does kind's release notes insist on using the @sha256 digest form (e.g. kindest/node:v1.37.0@sha256:...) instead of just the version tag?