We closed module 9 with an uncomfortable diagnosis: the Rutas Norte platform now knows how to scale on its own, survive the May bank-holiday weekend and protect itself during maintenance windows, but the k8s/ directory has grown into more than 120 YAML files duplicated across dev, pre and pro that somebody applies by hand from their laptop. Module 10 is about exactly that: the tools that surround Kubernetes and turn a pile of loose manifests into an operable system.
We start with the link closest to the development team: the local cluster. Before templating anything with Helm (10-03), overlaying anything with Kustomize (10-04) or reconciling anything with Argo CD (10-05), you need a cheap, disposable place to try changes out. In module 1 we brought up the practice cluster with minikube start --profile=rutas-norte almost as a formality, and we said we would come back to it. The moment has arrived: in this lesson we go deep into minikube, meet kind (the tool of choice in continuous integration), compare the five most common alternatives and, above all, learn what can and cannot be validated on a local cluster, which is the root of the classic "it works on my machine".
Contents
- Why every team needs a disposable cluster
- Minikube in depth: profiles and drivers
- Sizing, Kubernetes version and multi-node clusters
- The addon catalogue
- Working with local images without a registry
- Cluster access: tunnel, service, dashboard, mount and ssh
- The life cycle: stop, start, delete and logs
- kind in depth: nodes as containers
- Comparison table: minikube, kind, k3d, Docker Desktop and Rancher Desktop
- The differences from production that cause "it works on my machine"
- A reproducible recipe: the complete Rutas Norte environment locally
- Common mistakes and tips
- Exercises
- Conclusion
- Why every team needs a disposable cluster
At Rutas Norte S.L. there are three real environments: rutas-norte-dev, rutas-norte-pre and rutas-norte-pro. All three live on shared clusters. That means that when a developer wants to check whether her new NetworkPolicy breaks the connection between bookings-api and bookings-postgres, she has two bad options:
- Apply it in
rutas-norte-devand risk blocking the work of the other six colleagues who share that namespace. - Not test it at all and find out in
pre, with the waiting cycle that implies.
A local cluster resolves the dilemma. It is hers, free, quick to recreate and, if she breaks it, minikube delete and back to square one in three minutes.
What CAN be validated locally
| Aspect | Validatable locally? | Comment |
|---|---|---|
| Manifest syntax and validity | Yes, perfectly | kubectl apply --dry-run=server already validates against the real schema |
| Deployment behaviour, rollout and rollback | Yes | The controller is the same one as in production |
| liveness/readiness/startup probes | Yes | Same kubelet logic |
| ConfigMaps, Secrets and environment variables | Yes | Identical |
| RBAC: Roles, RoleBindings, ServiceAccounts | Yes | The API is the same |
| Ingress and host/path routing | Yes, with the addon or extraPortMappings |
A different controller, but the same rules |
| CRDs and operators | Yes | They install the same way |
| HPA with CPU metrics | Yes, with metrics-server |
The behaviour is realistic |
| Jobs, CronJobs, StatefulSets | Yes | Identical |
What CANNOT be validated locally
| Aspect | Why it fails | Where it was covered |
|---|---|---|
A real cloud load balancer (LoadBalancer Service) |
There is no cloud provider to assign an IP | 04-02 |
| Real storage performance and latency | The local StorageClass is a directory on the node | 05-04 |
| NetworkPolicies with the default CNI | Many local CNIs ignore them silently | 04-06 |
| Topology spread across zones | There is a single fictitious "zone" | 09-05 |
| Cluster Autoscaler and node addition | There is no elastic node group | 09-03 |
| Real resource quotas and pressure | Your laptop is not a 64 GB node | 03-04 |
| Identity federation with the cloud (IRSA, Workload Identity) | There is no identity provider | 10-06 |
| Inter-node latency and network partitions | Everything runs on the same machine | — |
The practical rule: the local cluster validates the correctness of your manifests and the logic of your application; it does not validate performance, topology or cloud integration.
flowchart LR
A[Laptop<br/>minikube / kind] -->|validates syntax,<br/>logic, RBAC| B[rutas-norte-dev]
B -->|validates integration,<br/>similar data| C[rutas-norte-pre]
C -->|validates load,<br/>topology, cost| D[rutas-norte-pro]
style A fill:#e8f4ff
style D fill:#ffe8e8
- Minikube in depth: profiles and drivers
minikube is a tool that brings up a single-node (or multi-node) Kubernetes cluster inside a virtual machine or a container on your own equipment. We have been using it since module 1; now we are going to understand what it does under the bonnet.
Profiles: several clusters at once
A profile is an independent cluster with its own name, its own configuration and its own kubectl context. It is the reason why in module 1 we wrote --profile=rutas-norte and not simply minikube start.
# Create (or start) the course cluster
minikube start --profile=rutas-norte
# Create a second cluster to try an old Kubernetes version
minikube start --profile=rutas-norte-old --kubernetes-version=v1.28.15
# List all profiles and their status
minikube profile listTypical output:
|--------------------|-----------|---------|--------------|------|---------|---------|-------|--------|
| Profile | VM Driver | Runtime | IP | Port | Version | Status | Nodes | Active |
|--------------------|-----------|---------|--------------|------|---------|---------|-------|--------|
| rutas-norte | docker | docker | 192.168.49.2 | 8443 | v1.30.4 | Running | 3 | * |
| rutas-norte-old | docker | docker | 192.168.58.2 | 8443 | v1.28.15| Stopped | 1 | |
|--------------------|-----------|---------|--------------|------|---------|---------|-------|--------|Without --profile, minikube uses the minikube profile. You can pin the active profile so you do not have to repeat it on every command:
minikube profile rutas-norte # from here on, every command targets this profile
minikube status # --profile is no longer neededTip: pinning the profile is convenient but dangerous, because it is invisible state. In the Rutas Norte team's scripts we always use an explicit --profile=rutas-norte.
Each profile also creates its kubectl context with the same name:
Drivers: where the node lives
The driver decides where the minikube node runs. It is the most important decision you make at the start.
| Driver | Platform | Isolation | Start-up speed | Notes |
|---|---|---|---|---|
docker |
Linux, macOS, Windows | Container | Very fast (~40 s) | The most widely used; the node is a container |
podman |
Mainly Linux | Container | Fast | Daemonless alternative; somewhat less battle-tested |
kvm2 |
Linux | Virtual machine | Medium (~90 s) | Real kernel isolation; ideal if you are testing kernel-level things |
hyperkit |
macOS Intel | Virtual machine | Medium | In practice superseded by docker or qemu on Apple Silicon |
qemu |
macOS Apple Silicon, Linux | Virtual machine | Slow | Useful when there is no Docker |
virtualbox |
Cross-platform | Virtual machine | Slow | Works everywhere, but the slowest of the lot |
none |
Linux | None | Instant | Installs Kubernetes directly on your machine; do not use it on a laptop |
How to choose, in three questions:
- Do you have Docker or Podman working? Use
--driver=docker(orpodman). It is the fastest and the least memory-hungry, because there is no full VM in the middle. - Do you need to test kernel modules, a demanding CNI or real
iptables? Usekvm2on Linux. A container shares the kernel with the host and some things simply cannot be isolated. - Are you on Windows without WSL2, or in a restricted corporate environment?
hypervorvirtualboxare the way out.
# Explicit driver choice
minikube start --profile=rutas-norte --driver=docker
# Set the default driver for all future profiles
minikube config set driver dockerWith the docker driver, the node is literally a container. You can see it:
CONTAINER ID IMAGE NAMES
a3f19c2b7d41 gcr.io/k8s-minikube/kicbase:v0.0.45 rutas-norte
8b7e2211ff05 gcr.io/k8s-minikube/kicbase:v0.0.45 rutas-norte-m02That kicbase is the image containing systemd, containerd, the kubelet and the tooling needed for a container to behave like a node.
- Sizing, Kubernetes version and multi-node clusters
Sizing
By default minikube asks for 2 CPUs and 2 GB of memory. For Rutas Norte that falls short as soon as we install Prometheus (07-03) and cert-manager (04-05):
minikube start \
--profile=rutas-norte \
--driver=docker \
--cpus=4 \
--memory=8192 \
--disk-size=40gWhat each option does:
--cpus=4: number of virtual CPUs assigned to the node. With fewer than 4, thekube-apiserverand Prometheus compete and the cluster feels sluggish.--memory=8192: memory in MiB (8 GB). You can also write8g. Careful: this memory is reserved away from your system; if your laptop has 16 GB, do not give it 12.--disk-size=40g: the node's disk size. Container images pile up fast; 20 GB run out within a couple of weeks of real work.
These values are remembered per profile. If tomorrow you run minikube start --profile=rutas-norte with no flags, it honours what you already configured. To change them you have to delete and recreate the profile (or use minikube config set beforehand).
An important warning: --cpus and --memory cannot be changed on the fly with the VM driver. If you come up short, minikube delete --profile=rutas-norte and create it again with the new values. That is why it pays to keep the creation script under version control (section 11).
Pinning the Kubernetes version
This is one of the most profitable and most forgotten practices. If rutas-norte-pro runs Kubernetes 1.30 and your minikube runs 1.32, you may write manifests that work on your laptop and are rejected in production, or the other way round: you may fail to spot a deprecated API.
# Check which specific versions your minikube knows about
minikube start --help | grep -A2 kubernetes-version
kubectl versionOutput:
Rutas Norte platform team rule: the local start-up script file declares the same minor version as production. When pro moves up a version, the script is updated in the same change.
Multi-node clusters
A single node makes it impossible to test anything related to scheduling: affinities (06-05), topologySpreadConstraints (09-05), DaemonSets with several targets (06-02) or draining a node. minikube knows how to create several:
minikube start \
--profile=rutas-norte \
--nodes=3 \
--cpus=2 \
--memory=4096 \
--kubernetes-version=v1.30.4NAME STATUS ROLES AGE VERSION
rutas-norte Ready control-plane 3m v1.30.4
rutas-norte-m02 Ready <none> 2m v1.30.4
rutas-norte-m03 Ready <none> 2m v1.30.4Note that --cpus and --memory apply per node: three nodes of 2 CPUs and 4 GB consume 6 CPUs and 12 GB of the host. It is easy to choke your laptop without noticing.
You can add or remove nodes afterwards:
And label the nodes to simulate zones and test the topology spread from 09-05:
kubectl label node rutas-norte-m02 topology.kubernetes.io/zone=zone-a
kubectl label node rutas-norte-m03 topology.kubernetes.io/zone=zone-bThis is a useful simulation to verify that the selector works, but remember: they are still on the same physical machine, so it does not test real tolerance to a zone going down.
- The addon catalogue
Addons are pre-packaged components that minikube knows how to install and maintain for you. They are what saves you from applying the Ingress controller or metrics-server manifests by hand.
|-----------------------------|--------------|--------------|
| ADDON NAME | PROFILE | STATUS |
|-----------------------------|--------------|--------------|
| csi-hostpath-driver | rutas-norte | disabled |
| dashboard | rutas-norte | disabled |
| default-storageclass | rutas-norte | enabled ✅ |
| ingress | rutas-norte | enabled ✅ |
| metrics-server | rutas-norte | enabled ✅ |
| registry | rutas-norte | disabled |
| storage-provisioner | rutas-norte | enabled ✅ |
| volumesnapshots | rutas-norte | disabled |
|-----------------------------|--------------|--------------|The ones this course uses, and why:
| Addon | What we use it for | Lesson |
|---|---|---|
ingress |
Deploys ingress-nginx for the www.rutasnorte.example rules |
04-04 |
metrics-server |
Feeds data to kubectl top and the HPA |
07-02, 09-01 |
storage-provisioner |
Provisions PVs dynamically for bookings-postgres |
05-05 |
default-storageclass |
Marks standard as the default StorageClass |
05-04 |
csi-hostpath-driver + volumesnapshots |
Required to practise CSI snapshots | 05-05 |
dashboard |
Web exploration interface | this lesson |
registry |
An image registry inside the cluster | this lesson |
# Enable the ones used in the course
minikube addons enable ingress --profile=rutas-norte
minikube addons enable metrics-server --profile=rutas-norte
minikube addons enable csi-hostpath-driver --profile=rutas-norte
minikube addons enable volumesnapshots --profile=rutas-norte
# Disable one that eats resources and you are not using
minikube addons disable dashboard --profile=rutas-norteInternally each addon is a set of manifests that minikube applies in the kube-system namespace (or one of its own). You can look at them:
Important notice: minikube's ingress addon installs ingress-nginx with a configuration designed for a single node. In production, your platform team probably installs it with Helm and with very different values (replicas, PDB, resources). The behaviour of the rules is the same; the behaviour of availability is not.
- Working with local images without a registry
In day-to-day practice, this is where most time gets lost. You have built bookings-api:dev-local on your laptop and the pod sits in ImagePullBackOff, because the minikube node has its own image store, separate from the Docker on your machine.
There are three ways to solve it.
Option A: minikube image load (the simplest and the one we recommend)
# 1. You build the image on your machine, as usual
docker build -t registry.rutasnorte.example/bookings-api:dev-local ./bookings-api
# 2. You copy it into the minikube node's store
minikube image load registry.rutasnorte.example/bookings-api:dev-local --profile=rutas-norte
# 3. You check it is there
minikube image ls --profile=rutas-norte | grep bookings-apiIn the manifest, the key is to stop Kubernetes from trying to pull it from the registry:
apiVersion: apps/v1
kind: Deployment
metadata:
name: bookings-api
namespace: rutas-norte-dev
spec:
replicas: 1
selector:
matchLabels:
app: bookings-api
template:
metadata:
labels:
app: bookings-api
app.kubernetes.io/part-of: rutas-norte
spec:
containers:
- name: api
image: registry.rutasnorte.example/bookings-api:dev-local
# KEY POINT: with IfNotPresent, the kubelet uses the local image
# and does not try to contact the registry.
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080Classic trap: if the image tag is latest, Kubernetes applies imagePullPolicy: Always by default and will always try to pull it, ignoring the local copy. Never use latest (we already saw this in 08-05); here it additionally breaks your local workflow.
If there are several nodes, image load copies it to all of them automatically.
Option B: minikube docker-env (building inside the node)
This option repoints your Docker client at the daemon running inside the minikube node. Whatever you build is immediately available to the kubelet, with no copy step.
export DOCKER_TLS_VERIFY="1"
export DOCKER_HOST="tcp://192.168.49.2:2376"
export DOCKER_CERT_PATH="/home/user/.minikube/certs"
export MINIKUBE_ACTIVE_DOCKERD="rutas-norte"# Apply it to the current terminal session
eval $(minikube docker-env --profile=rutas-norte)
# From here on, docker build builds INSIDE the node
docker build -t registry.rutasnorte.example/bookings-api:dev-local ./bookings-api
# Undo it when you are done
eval $(minikube docker-env --unset --profile=rutas-norte)Advantage: it is faster in the edit-build-test loop, because there is no copy. Disadvantage: it only works with the docker runtime; if the profile uses containerd, it does not apply. And it is terminal state that is easy to forget (you have built inside the node and then cannot find the image on your machine).
Option C: the registry addon
This brings up a registry inside the cluster. It is the option closest to production, but also the one demanding the most steps (you have to push the image, resolve the name from the node). For day-to-day work, option A wins nearly every time.
| Option | Loop speed | Complexity | When to use it |
|---|---|---|---|
image load |
Medium (a copy each time) | Very low | General use, multi-node |
docker-env |
High | Low, but with hidden state | Heavy iteration on a single node with the docker runtime |
registry addon |
Low | High | When you want to reproduce the real registry workflow |
- Cluster access: tunnel, service, dashboard, mount and ssh
minikube tunnel: LoadBalancer Services
When you declare a Service of type LoadBalancer (04-02), Kubernetes asks a cloud provider controller to provision a balancer. On your laptop there is no cloud provider, so the Service stays in <pending> for ever:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
web-store LoadBalancer 10.104.12.201 <pending> 80:31820/TCP 2mminikube tunnel simulates that balancer: it creates routes on your machine and assigns an external IP.
# Run it in a SEPARATE terminal and leave it open.
# It asks for the administrator password because it creates network routes.
minikube tunnel --profile=rutas-norteStatus:
machine: rutas-norte
pid: 48211
route: 10.96.0.0/12 -> 192.168.49.2
minikube: Running
services: [web-store]Now, in another terminal:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
web-store LoadBalancer 10.104.12.201 10.104.12.201 80:31820/TCP 5mPoints that confuse everybody the first time:
- The process must stay alive. If you close the terminal, the IP goes back to
<pending>. - It is not a real balancer: there are no provider health checks, no distribution across zones, no cloud firewall rules. It only routes.
- On macOS and Windows with the
dockerdriver,tunnelis also needed to reach the Ingress.
minikube service: opening a Service in the browser
For NodePort Services (or even ClusterIP ones with --url), this is the quick route:
# Opens the browser pointing at the service
minikube service web-store -n rutas-norte-dev --profile=rutas-norte
# Only print the URL, without opening a browser (useful in scripts)
minikube service web-store -n rutas-norte-dev --url --profile=rutas-norteminikube dashboard: the web interface
minikube dashboard --profile=rutas-norte
minikube dashboard --url --profile=rutas-norte # the URL onlyIt enables the dashboard addon if it was not already on, brings up a proxy and opens the browser. Useful for exploring the cluster visually while you are learning; in professional day-to-day work kubectl and k9s are faster.
minikube mount: sharing a host directory
Sometimes you want the pod to see a directory from your laptop: an SQL dump of test data for bookings-postgres, or the static files for web-store.
# Separate terminal, it stays in the foreground
minikube mount ~/rutas-norte/test-data:/test-data --profile=rutas-norteIn the pod you use a hostPath (05-01) pointing at /test-data, which now exists inside the node:
Warning: hostPath is forbidden by the restricted level of the Pod Security Standards (08-03). Locally it is acceptable for test data; in rutas-norte-pro it would be rejected, and rightly so.
minikube ssh: getting inside the node
It drops you inside the node. Useful for:
# Check the node's disk space when there are eviction problems
df -h /var
# List the images the runtime holds
sudo crictl images
# Look at the control plane's static pods (we will come back to this in 10-02)
ls /etc/kubernetes/manifests
# Leave
exitOn a multi-node cluster, pick the node:
- The life cycle: stop, start, delete and logs
# Shut the cluster down keeping everything (objects, images, PV data)
minikube stop --profile=rutas-norte
# Start it again exactly as it was
minikube start --profile=rutas-norte
# Current status
minikube status --profile=rutas-norterutas-norte
type: Control Plane
host: Running
kubelet: Running
apiserver: Running
kubeconfig: Configured# Delete the cluster (irreversible: objects, PVs and images are lost)
minikube delete --profile=rutas-norte
# Delete ALL profiles in one go (full clean-up)
minikube delete --all --purge--purge additionally removes the ~/.minikube directory with the configuration and the Kubernetes image cache. Use it when minikube is in a strange state you cannot explain; it is the equivalent of turning it off and on again.
Diagnosis with logs
When minikube start fails or the cluster comes up only halfway:
minikube logs --profile=rutas-norte
# Only the problems detected, far more readable
minikube logs --problems --profile=rutas-norte
# Follow live
minikube logs -f --profile=rutas-norte==> Audit <==
==> kubelet <==
Sep 12 09:14:02 rutas-norte kubelet[2311]: E0912 09:14:02.118 Failed to
create pod sandbox: rpc error: code = Unknown desc = failed to set up
sandbox network: plugin type="bridge" failedThat kind of message almost always means the node ran out of resources or the container's network state was corrupted after an abrupt shutdown. minikube delete && minikube start fixes it, and that is exactly why a local cluster must be disposable.
- kind in depth: nodes as containers
kind (Kubernetes IN Docker) takes a different approach and a narrower philosophy: every cluster node is a Docker container running containerd and the kubelet. There are no addons, no tunnel, no dashboard. Just Kubernetes, very fast and very reproducible.
Precisely because of that austerity, it is the tool the Kubernetes project itself uses for its tests and the usual choice in continuous integration.
A minimal cluster
Creating cluster "rutas-norte" ...
✓ Ensuring node image (kindest/node:v1.30.4) 🖼
✓ Preparing nodes 📦
✓ Writing configuration 📜
✓ Starting control-plane 🕹️
✓ Installing CNI 🔌
✓ Installing StorageClass 💾
Set kubectl context to "kind-rutas-norte"It takes about 25 seconds. The context is called kind-rutas-norte (with the kind- prefix).
Configuration file: several nodes, ports and mounts
Here is where kind's real power lies: the cluster is declared in a file you can version.
# k8s/local/kind-rutas-norte.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: rutas-norte
# We match the pod network to production's so that
# NetworkPolicies with CIDR blocks behave the same way.
networking:
podSubnet: "10.244.0.0/16"
serviceSubnet: "10.96.0.0/16"
# We disable the default CNI in order to install Calico and thus
# BE ABLE to genuinely test the NetworkPolicies from 04-06.
disableDefaultCNI: false
nodes:
# --- Control plane ---
- role: control-plane
image: kindest/node:v1.30.4
# We publish the container's ports 80 and 443 on the laptop,
# so the Ingress is reachable at http://localhost without tunnel.
extraPortMappings:
- containerPort: 80
hostPort: 80
protocol: TCP
- containerPort: 443
hostPort: 443
protocol: TCP
# Label required by the ingress-nginx controller for kind.
kubeadmConfigPatches:
- |
kind: InitConfiguration
nodeRegistration:
kubeletExtraArgs:
node-labels: "ingress-ready=true"
# --- Worker nodes, labelled as different zones ---
- role: worker
image: kindest/node:v1.30.4
labels:
topology.kubernetes.io/zone: zone-a
# We mount a laptop directory inside the node.
extraMounts:
- hostPath: /home/user/rutas-norte/test-data
containerPath: /test-data
readOnly: true
- role: worker
image: kindest/node:v1.30.4
labels:
topology.kubernetes.io/zone: zone-bNAME STATUS ROLES AGE VERSION
rutas-norte-control-plane Ready control-plane 47s v1.30.4
rutas-norte-worker Ready <none> 32s v1.30.4
rutas-norte-worker2 Ready <none> 32s v1.30.4Let us go over the key fields:
extraPortMappings: publishes a port of the node-container on your machine. It is kind's answer to the external access problem. WithhostPort: 80, once ingress-nginx is installed,curl -H "Host: www.rutasnorte.example" http://localhostreachesweb-store. It can only be defined when creating the cluster: if you forget it, you have to recreate it.extraMounts: the equivalent ofminikube mount, but declarative and with no foreground process. Far more convenient.kubeadmConfigPatches: kind uses kubeadm internally (we will see it in 10-02). This field lets you modify its configuration; here we use it to set theingress-ready=truelabel that the ingress-nginx manifest for kind expects in itsnodeSelector.image: pins the Kubernetes version. Just as with minikube, match it to production.labels: node labels applied from the outset, with no subsequentkubectl label.
Installing the Ingress on kind
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
kubectl wait --namespace ingress-nginx \
--for=condition=ready pod \
--selector=app.kubernetes.io/component=controller \
--timeout=120sNow it works:
Loading local images
docker build -t registry.rutasnorte.example/bookings-api:dev-local ./bookings-api
kind load docker-image registry.rutasnorte.example/bookings-api:dev-local --name rutas-norteJust as in minikube, the image is copied to all the nodes. And here too you need imagePullPolicy: IfNotPresent and to avoid the latest tag.
You can also load an exported image file, handy in pipelines where the build and the load happen in separate steps:
docker save registry.rutasnorte.example/bookings-api:dev-local -o api.tar
kind load image-archive api.tar --name rutas-norteWhy kind rules in continuous integration
| Reason | Explanation |
|---|---|
| Speed | 25-40 s against minikube's 60-120 s; in a pipeline that runs 40 times a day, that is hours |
| No nested virtualisation | Being containers, it works on CI runners that do not allow KVM |
| Configuration in a file | The cluster is reproducible bit for bit from the repository |
| Cheap multi-node | Adding a node means adding a container |
| Complete clean-up | kind delete cluster leaves no trace |
| No hidden state | There are no persistent profiles or configuration remembered between runs |
A typical step in the ci-rutasnorte pipeline boils down to: kind create cluster --config kind-ci.yaml --wait 120s, build and load the image, kubectl apply -k k8s/environments/dev (that is Kustomize, we will see it in 10-04), wait for the rollout, run the smoke tests and kind delete cluster at the end whatever happens.
- Comparison table: minikube, kind, k3d, Docker Desktop and Rancher Desktop
| Criterion | minikube | kind | k3d | Docker Desktop | Rancher Desktop |
|---|---|---|---|---|---|
| What it is | Local cluster with addons | Nodes as containers | k3s in containers | Kubernetes built into the app | Desktop app with k3s |
| Start-up (1 node) | 60-120 s | 25-40 s | 15-25 s | ~90 s (when enabling it) | ~60 s |
| Multi-node | Yes (--nodes) |
Yes (file) | Yes (--agents) |
No | No |
| Idle consumption | Medium-high | Medium | Low | High | Medium |
| Built-in addons | Many (ingress, metrics, CSI, registry) | None | Traefik and a local server included | None | Traefik included |
| Configuration in a file | Partial (flags + config set) |
Complete and versionable | Complete and versionable | No | Partial |
| Suitability for CI | Fair (slow, needs a driver) | Excellent | Excellent | None | Low |
| Fidelity to standard Kubernetes | High | Very high (real kubeadm) | Medium (k3s trims components) | High | Medium (k3s) |
| Built-in local registry | registry addon |
No (set up separately) | Yes (k3d registry) |
Shares Docker's | Shares its own |
| Commercial licence | Free | Free | Free | Paid for large companies | Free |
| Best for | Learning and daily work with extras | CI and reproducible tests | Ultra-fast loops, small teams | Those who already have it and want no more tools | A free replacement for Docker Desktop |
Notes to help you choose:
- k3s (the engine behind k3d and Rancher Desktop) is a lightweight, conformant distribution, but it swaps out some pieces: it uses Traefik as its Ingress, SQLite instead of etcd by default and disables cloud components. It is perfect for developing; it is misleading if you want to check fine-grained control plane behaviour.
- Docker Desktop with Kubernetes enabled is the most convenient thing if you already have it, but it is a single node, not configurable, and its licence is paid for companies above a certain size. Rutas Norte S.L. is small, so it is unaffected, but it is a real factor in team decisions.
- This course's recommendation: minikube for daily work (because of the addons, which save a lot of manual installation) and kind in the
ci-rutasnortepipeline (for speed and reproducibility). Having two different tools is not a problem as long as the manifest you deploy is the same.
- The differences from production that cause "it works on my machine"
This section is the most important of the lesson. Every point is a real incident waiting to happen.
10.1 There is no cloud balancer
Locally, type: LoadBalancer does nothing without minikube tunnel. In rutas-norte-pro, that same Service provisions a real balancer, with its public IP, its monthly cost and its provisioning time of several minutes.
Consequences you only see in the cloud: provider-specific annotations (balancer type, certificates, access lists), the provider's own health checks, and the fact that deleting the Service deletes the balancer. We will see this in 10-06.
Mitigation: locally, use Ingress (04-04) and not LoadBalancer in the application manifests. Leave LoadBalancer for the Ingress controller alone, which locally is published via NodePort or extraPortMappings.
10.2 The storage class is a different one
In minikube, the standard StorageClass is a node directory served by storage-provisioner. In rutas-norte-pro, bookings-postgres uses a network-disk StorageClass with provisioned IOPS (05-04).
| Difference | Local | Production |
|---|---|---|
| Access mode | ReadWriteOnce, but with one node everything "works" |
Genuine ReadWriteOnce: two pods on different nodes fail |
| Performance | Your laptop's SSD | A network disk, with latency and limits |
| Expansion and snapshots | May not be supported | Yes, native to the provider |
| Binding | Immediate | Frequently WaitForFirstConsumer |
That last point is treacherous: in production the PV is not created until the pod is scheduled, and that changes the order of events. A StatefulSet that starts fine locally can end up Pending in the cloud because of an incompatible zone affinity.
10.3 The default CNI may ignore NetworkPolicies
This is the most dangerous one because it fails silently. You write a NetworkPolicy isolating bookings-postgres so that only bookings-api can talk to it (04-06), you apply it on your minikube, the object is created without error... and it does absolutely nothing, because the default CNI does not implement it.
It looks like it works. But:
# From a pod that should NOT be able to connect
kubectl run curious --rm -it --image=busybox:1.36 -n rutas-norte-dev -- \
nc -zv bookings-postgres 5432It connects. The policy was ignored.
How to detect it: kubectl get pods -n kube-system -o wide | grep -Ei 'calico|cilium|flannel|kindnet'. kindnet (kind's default) and the basic bridge do not implement NetworkPolicies; calico and cilium do.
Mitigation: on minikube, --cni=calico. On kind, disableDefaultCNI: true with podSubnet: "192.168.0.0/16" and applying the Calico manifest afterwards.
And the golden rule: a NetworkPolicy is not tested until you have verified that something which used to connect no longer connects. Testing only that what is allowed still works proves nothing.
10.4 There are no multiple zones and no node scaling
topologySpreadConstraints (09-05) with topologyKey: topology.kubernetes.io/zone on a single-node cluster is satisfied trivially. Labelling the nodes of a multi-node cluster simulates zones and verifies that the selector and the labels are correct, but it does not test fault tolerance.
The same goes for the Cluster Autoscaler (09-03): locally there is no elastic node group, so a pod that is Pending for lack of resources stays Pending for ever. In production a new node would appear within two minutes. The local symptom is identical to a serious production failure, which is misleading.
10.5 Summary of mitigations
| Risk | Local mitigation | Definitive test |
|---|---|---|
| Missing balancer | Use Ingress; minikube tunnel if needed |
rutas-norte-pre |
| Different StorageClass | Name the class in the manifest, do not rely on the default one | rutas-norte-pre |
| Ignored NetworkPolicies | Install Calico or Cilium locally | rutas-norte-pre with a negative test |
| Topology and zones | Label nodes to validate selectors | rutas-norte-pro |
| Node scaling | Cannot be simulated | rutas-norte-pre |
| Resources and limits | Set deliberately low limits to see what gets throttled | Load tests with k6 (09-06) |
- A reproducible recipe: the complete Rutas Norte environment locally
The worst thing about a local environment is that everyone on the team has a different one. The solution is a script versioned in the repository, alongside the manifests.
#!/usr/bin/env bash
# k8s/local/start-local.sh
# Brings up the complete Rutas Norte local environment on minikube.
# Usage: ./start-local.sh (creates and configures)
# ./start-local.sh delete (destroys the profile)
set -euo pipefail
PROFILE="rutas-norte"
# Must match the minor version of rutas-norte-pro.
K8S_VERSION="v1.30.4"
NODES=3
CPUS=2
MEMORY=4096
DISK="30g"
if [[ "${1:-}" == "delete" ]]; then
echo "==> Deleting profile ${PROFILE}"
minikube delete --profile="${PROFILE}"
exit 0
fi
echo "==> 1/6 Creating cluster ${PROFILE} (Kubernetes ${K8S_VERSION})"
minikube start \
--profile="${PROFILE}" \
--driver=docker \
--kubernetes-version="${K8S_VERSION}" \
--nodes="${NODES}" \
--cpus="${CPUS}" \
--memory="${MEMORY}" \
--disk-size="${DISK}" \
--cni=calico # essential for NetworkPolicies to be enforced
echo "==> 2/6 Enabling addons"
for addon in ingress metrics-server csi-hostpath-driver volumesnapshots; do
minikube addons enable "${addon}" --profile="${PROFILE}"
done
echo "==> 3/6 Labelling nodes as simulated zones"
kubectl label node "${PROFILE}-m02" topology.kubernetes.io/zone=zone-a --overwrite
kubectl label node "${PROFILE}-m03" topology.kubernetes.io/zone=zone-b --overwrite
echo "==> 4/6 Creating the development namespace"
kubectl create namespace rutas-norte-dev --dry-run=client -o yaml | kubectl apply -f -
kubectl label namespace rutas-norte-dev \
app.kubernetes.io/part-of=rutas-norte environment=dev --overwrite
echo "==> 5/6 Building and loading the local images"
for comp in web-store bookings-api notifications-worker; do
docker build -t "registry.rutasnorte.example/${comp}:dev-local" "./${comp}"
minikube image load "registry.rutasnorte.example/${comp}:dev-local" --profile="${PROFILE}"
done
echo "==> 6/6 Waiting for the Ingress controller to be ready"
kubectl wait --namespace ingress-nginx \
--for=condition=ready pod \
--selector=app.kubernetes.io/component=controller \
--timeout=180s
echo
echo "Cluster ready. To reach the Ingress:"
echo " echo \"\$(minikube ip --profile=${PROFILE}) www.rutasnorte.example api.rutasnorte.example\" | sudo tee -a /etc/hosts"
echo "Deploy the application with: kubectl apply -k k8s/environments/dev"The reasoning behind the script's decisions:
set -euo pipefail: aborts on the first command that fails, instead of carrying on and leaving a half-built cluster.- The Kubernetes version in a variable at the top: when
promoves up a version, you change one line. --cni=calico: the difference between genuinely testing NetworkPolicies and fooling yourself.--dry-run=client -o yaml | kubectl apply -f -: the idempotent pattern from 01-06; it creates the namespace if it does not exist and does not fail if it does.kubectl wait: the script does not finish until the cluster is genuinely usable. Without this, whoever runs it tries to deploy and fails because the Ingress is not up yet.- The closing note about
/etc/hosts: the domainswww.rutasnorte.exampleandapi.rutasnorte.exampledo not exist in any DNS; they have to be resolved by hand against the node's IP.
The kind equivalent is even shorter, because all the cluster configuration is already in kind-rutas-norte.yaml: kind create cluster --config ... --wait 180s is enough, then apply Calico and ingress-nginx, wait for the controller and load the images with kind load docker-image. And there is no need to touch /etc/hosts: thanks to extraPortMappings, the Ingress responds at http://localhost with the right Host header.
Common Mistakes and Tips
1. Giving minikube nearly all the laptop's memory. With 16 GB physical, --memory=12288 leaves the operating system no headroom and everything stutters. Rule: no more than half the physical RAM, and remember that in multi-node the value is multiplied by the number of nodes.
2. Forgetting that --cpus and --memory are recorded in the profile. You run minikube start --profile=rutas-norte --memory=8192 one day, and the next day minikube start --profile=rutas-norte with no flags: it still uses 8192. If you think you changed the value and nothing changed, this is why. To change it you have to recreate the profile.
3. ImagePullBackOff with an image you "definitely built". It is almost always one of two causes: you did not run minikube image load / kind load docker-image, or the tag is latest and the implicit imagePullPolicy is Always. Use explicit tags (dev-local) and imagePullPolicy: IfNotPresent.
4. Believing an applied NetworkPolicy is working. Always check the CNI and run the negative test: attempt the connection that is meant to be blocked and verify that it times out. Without that test, you have tested nothing.
5. Closing the minikube tunnel or minikube mount terminal. Both are foreground processes. If the external IP or the mounted directory disappears, check whether the process is still alive before hunting for exotic causes.
6. Discovering too late that an extraPortMapping was missing in kind. They cannot be added to an already-created cluster. Have the configuration file with ports 80, 443 and perhaps a couple more from the very beginning.
7. Using latest for the node image. Both kindest/node and the minikube version must be pinned. A colleague on v1.31 and you on v1.29 produce different results from the same manifests.
8. Accumulating forgotten profiles. Every stopped profile still takes up disk. Run minikube profile list now and then and minikube delete --profile=X for whatever you are not using.
9. Treating the local cluster as if it were precious. If you suspect something is in a strange state, delete it and recreate it. The script in section 11 makes that cost three minutes; investigating a corrupted state costs an afternoon.
10. Testing performance locally. A benchmark of bookings-postgres on your SSD says nothing about the network disk in production. That is what rutas-norte-pre and k6 (09-06) are for.
Exercises
Exercise 1: multi-node cluster with simulated zones
Create a minikube profile called rutas-norte-topology with 3 nodes, Kubernetes v1.30.4 and Calico as the CNI. Label the two worker nodes as zone-a and zone-b. Deploy bookings-api with 4 replicas and a topologySpreadConstraint that spreads across zones with maxSkew: 1, and check with kubectl get pods -o wide that the split is 2 and 2.
Exercise 2: proving that the CNI matters
Create two kind clusters: one with the default CNI (kindnet) and another with disableDefaultCNI: true and Calico installed. In both, apply a deny-all ingress NetworkPolicy in the rutas-norte-dev namespace and check, using a busybox pod trying to connect to a Service, which of the two genuinely honours it.
Exercise 3: development loop with a local image
With the rutas-norte profile running, build an image registry.rutasnorte.example/web-store:dev-local, load it into minikube and deploy it with an Ingress for www.rutasnorte.example. Then change the page content, rebuild with the same tag, reload the image and get the pod to serve the new content. Explain why a plain kubectl apply is not enough.
Solutions
Solution 1
minikube start --profile=rutas-norte-topology \
--driver=docker --kubernetes-version=v1.30.4 \
--nodes=3 --cpus=2 --memory=3072 --cni=calico
kubectl label node rutas-norte-topology-m02 topology.kubernetes.io/zone=zone-a
kubectl label node rutas-norte-topology-m03 topology.kubernetes.io/zone=zone-b
kubectl create namespace rutas-norte-dev# bookings-api-topology.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: bookings-api
namespace: rutas-norte-dev
spec:
replicas: 4
selector:
matchLabels: { app: bookings-api }
template:
metadata:
labels:
app: bookings-api
app.kubernetes.io/part-of: rutas-norte
spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels: { app: bookings-api }
containers:
- name: api
image: nginx:1.27-alpine
resources:
requests: { cpu: 50m, memory: 64Mi }You should get 2 pods on -m02 and 2 on -m03. The control plane node receives no pods because it carries the corresponding taint (06-05).
Solution 2
# kind-no-netpol.yaml -> nodes: [{role: control-plane}], no networking section
# kind-with-netpol.yaml:
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: with-netpol
networking:
disableDefaultCNI: true
podSubnet: "192.168.0.0/16"
nodes: [{role: control-plane}]
---
# deny-all.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: deny-ingress, namespace: rutas-norte-dev }
spec:
podSelector: {}
policyTypes: [Ingress]for c in no-netpol with-netpol; do
kind create cluster --config kind-${c}.yaml
[ "$c" = "with-netpol" ] && kubectl apply -f https://raw.githubusercontent.com/projectcalico/calico/v3.28.1/manifests/calico.yaml && sleep 60
kubectl create ns rutas-norte-dev
kubectl -n rutas-norte-dev create deploy web --image=nginx:1.27-alpine
kubectl -n rutas-norte-dev expose deploy web --port=80
kubectl apply -f deny-all.yaml
echo "--- $c ---"
kubectl -n rutas-norte-dev run p --rm -i --restart=Never --image=busybox:1.36 -- \
timeout 5 wget -qO- http://web || echo "BLOCKED (correct)"
doneOn no-netpol it returns nginx's HTML: the policy was ignored. On with-netpol it times out: Calico enforces it.
Solution 3
docker build -t registry.rutasnorte.example/web-store:dev-local ./web-store
minikube image load registry.rutasnorte.example/web-store:dev-local --profile=rutas-norte
kubectl apply -f k8s/base/web-store.yaml # with imagePullPolicy: IfNotPresent
# --- after editing the content ---
docker build -t registry.rutasnorte.example/web-store:dev-local ./web-store
minikube image load registry.rutasnorte.example/web-store:dev-local --profile=rutas-norte
kubectl rollout restart deploy/web-store -n rutas-norte-devWhy kubectl apply is not enough: the manifest has not changed (same image tag), so the object in the API is identical and the Deployment does not generate a new ReplicaSet. rollout restart adds an annotation with a timestamp to the pod template, which does trigger the rollout. This is exactly why production uses immutable tags with a digest (08-05): the new image has a new identifier and the change propagates on its own.
Conclusion
You now know how to build and destroy local clusters with judgement. The points worth taking away:
- minikube shines thanks to its addons:
ingress,metrics-server,storage-provisionerandcsi-hostpath-driversave you installing half a platform by hand. Profiles let you keep several clusters at once,--kubernetes-versionaligns your environment with production, and--nodesopens the door to testing scheduling and topology. - kind wins on speed and reproducibility: the nodes are containers, the whole cluster is declared in a versioned file with
extraPortMappingsandextraMounts, and that is why it is the natural choice for theci-rutasnortepipeline. - The local image workflow is solved with
minikube image loadorkind load docker-image, always withimagePullPolicy: IfNotPresentand tags that are notlatest. - And most importantly: a local cluster validates correctness, not realism. There is no cloud balancer, the StorageClass is a different one, the default CNI may silently ignore your NetworkPolicies, and there are no zones and no node scaling. Knowing exactly what you are not testing is what prevents "it works on my machine".
One question remains open: local clusters are controlled toys, but how do you build a real cluster, on your own machines, without a cloud provider doing it for you? In the next lesson, Kubeadm, we will look at the official tool for bootstrapping a conformant cluster: preparing the machines, installing containerd, running kubeadm init with a configuration file, joining nodes, building a highly available control plane, renewing certificates and upgrading versions. And we will discover why kind, on the inside, is nothing more than kubeadm inside a container.
Kubernetes Course
Module 1: Introduction to Kubernetes
- What Is Kubernetes?
- Kubernetes Architecture
- Key Concepts and Terminology
- Setting Up a Kubernetes Cluster
- The Kubernetes CLI: kubectl
- Objects, YAML Manifests and the Declarative Model
- The Course Project: the Rutas Norte Platform
Module 2: Core Kubernetes Components
- Pods
- ReplicaSets
- Deployments
- Updates, Rollbacks and Deployment Strategies
- Services
- Namespaces
- Labels, Selectors and Annotations
Module 3: Configuration and Secret Management
- ConfigMaps
- Secrets
- Environment Variables
- Resource Quotas and Limits
- LimitRanges and Quality of Service (QoS) Classes
- ServiceAccounts and API Access from Pods
Module 4: Networking in Kubernetes
- Cluster Networking
- Service Types
- Internal DNS and Service Discovery
- Ingress Controllers
- TLS and Certificate Management with cert-manager
- Network Policies
Module 5: Storage in Kubernetes
- Volumes
- Persistent Volumes
- Persistent Volume Claims
- Storage Classes
- Dynamic Provisioning, Expansion and Snapshots
- Backup and Restore of Persistent Data
Module 6: Advanced Kubernetes Concepts
- StatefulSets
- DaemonSets
- Jobs and CronJobs
- Init Containers, Sidecars and Multi-Container Patterns
- Scheduling: Affinity, Taints and Tolerations
- Custom Resource Definitions (CRDs)
- Operators and the Controller Pattern
Module 7: Monitoring and Logging
- Health Checks and Probes
- Metrics Server and kubectl top
- Monitoring with Prometheus
- Visualization and Alerting with Grafana and Alertmanager
- Centralized Logging with Elasticsearch, Fluentd and Kibana (EFK)
- Application Debugging and Cluster Events
Module 8: Kubernetes Security
- Role-Based Access Control (RBAC)
- Security Contexts and Container Hardening
- Pod Security Policies and Pod Security Standards
- Network Security
- Image Security
- Auditing, Scanning and Vulnerability Management
Module 9: Scaling and Performance
- Horizontal Pod Autoscaling
- Vertical Pod Autoscaling
- Cluster Autoscaling
- Event-Driven and Custom-Metric Scaling with KEDA
- High Availability: PodDisruptionBudgets and Topology
- Performance Tuning
Module 10: Kubernetes Ecosystem and Tooling
- Minikube and Local Environments with kind
- Kubeadm
- Helm
- Kustomize
- GitOps with Argo CD and Flux
- Managed Kubernetes: EKS, AKS and GKE
Module 11: Case Studies and Real-World Applications
- Deploying a Web Application
- Running Stateful Applications
- CI/CD with Kubernetes
- Deployment Strategies: Blue-Green and Canary
- Multi-Cluster Management
- Production Operations: Incidents, Runbooks and Costs
