You will create a local Kubernetes cluster, learn the core kubectl workflow, and run your first deployment. By the end you will understand how the control plane keeps your desired state running.

This lab is the foundation for every other Kubernetes writeup on this site. Instead of reading about pods and controllers in the abstract, you will stand up a real (but disposable) cluster on your own machine, push workloads into it, break them on purpose, and watch Kubernetes put them back together. Every command below is safe to run on a laptop, and the whole environment can be deleted with a single command at the end.

We use kind because it is the tool the Kubernetes project itself uses for testing, it needs nothing beyond Docker, and it behaves enough like a production cluster that the skills transfer directly. If you prefer minikube, every kubectl command in this lab is identical; only the cluster-creation step differs.

Learning Objectives

  • Spin up a local cluster with kind or minikube.
  • Use kubectl to create and inspect workloads.
  • Run a self-healing deployment.
  • Scale a deployment and read pod logs.
  • Move from imperative commands to a declarative YAML manifest.
  • Time: ~3 hours · Difficulty: Intermediate · Prereqs: Docker and kubectl installed.

Architecture Overview

Everything in Kubernetes flows through the API server. When you run kubectl create deployment, you are not starting containers directly, you are writing a desired state record into etcd. The scheduler then picks a node for each pending pod, and the controller manager continuously compares what is running against what you asked for, fixing any drift. This reconciliation loop is the single most important idea in Kubernetes, and it is what gives you self-healing for free.

The key players you will interact with in this lab:

  • API server, the front door. kubectl, the scheduler, and every controller all talk to it; nothing talks to anything else directly.
  • etcd, the key-value store holding the cluster's desired state. Lose it and the cluster has amnesia.
  • Scheduler, watches for pods with no node assigned and binds them to one based on resources and constraints.
  • Controller manager, runs the reconciliation loops (Deployment, ReplicaSet, Node controllers, and more) that keep reality matching your declared state.
  • kubelet, the agent on each node that actually starts and monitors the containers the scheduler assigned to it.

In a kind cluster all of the control-plane components run as containers inside a single Docker container named lab-control-plane, but the behaviour is the same as a multi-node production setup.

Environment Setup

Prerequisites

  • Docker Engine running locally, plus kind or minikube and kubectl installed.
  • A few GB of free RAM; the local cluster runs entirely on your machine.
  • Assumed skills: basic Docker (images, containers) and comfort with YAML.

You will need: Docker, kubectl, and kind (or minikube).

Before you begin: confirm kubectl is installed with kubectl version --client.

Docker must be running, not just installed, kind builds the cluster out of Docker containers, so if the Docker daemon is down, cluster creation fails immediately. On macOS and Windows that means Docker Desktop must be open; on Linux, the docker service must be active.

Verify all three prerequisites before continuing:

$ docker info --format '{{.ServerVersion}}'
27.3.1
$ kubectl version --client
Client Version: v1.36.0
$ kind version
kind v0.32.0

If any of these fail, install the missing piece first:

INSTALL KIND (macOS / Linux)

# macOS with Homebrew
brew install kind

# Linux - download the release binary
curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.32.0/kind-linux-amd64
chmod +x ./kind && sudo mv ./kind /usr/local/bin/kind

kubectl versions within one minor release of the cluster version are fully supported, so a v1.36 client against a v1.35, v1.36, or v1.37 cluster is fine. Only worry if the skew is larger than that.

Step-by-Step Execution

01
Create a local cluster

kind boots a single-node Kubernetes cluster where the node itself is a Docker container. The --name lab flag names the cluster (and its kubectl context, which becomes kind-lab), so you can run several throwaway clusters side by side without them colliding. The first run pulls the kindest/node image, which is a few hundred MB, so expect a minute or two; later clusters on the same machine start in seconds.

kind create cluster --name lab
Boots a single-node Kubernetes cluster in Docker.
Creating cluster "lab" ... ✓ Ensuring node image (kindest/node:v1.36.1) 🛠 ✓ Preparing nodes 📦 ✓ Writing configuration 🖉 ✓ Starting control-plane ️ ✓ Installing CNI 🔨 ✓ Installing StorageClass 💾 Set kubectl context to "kind-lab" You can now use your cluster with: kubectl cluster-info --context kind-lab

kind also merges credentials into your kubeconfig (~/.kube/config) and switches the active context automatically, which is why kubectl works immediately afterwards with no extra configuration.

Verify: the cluster answers and the context is what you expect.

$ kubectl cluster-info --context kind-lab
Kubernetes control plane is running at https://127.0.0.1:51234 CoreDNS is running at https://127.0.0.1:51234/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy
$ kubectl config current-context
kind-lab

Common mistake: running this with Docker Desktop closed. The error Cannot connect to the Docker daemon means exactly that, start Docker and retry. Also note the API server port (51234 above) is random per machine; never hard-code it.

Expected output:

$ kind create cluster --name lab
Creating cluster "lab" ...
  Ensuring node image (kindest/node:v1.36.1)
  Ready: nodes joined the cluster

What this teaches: A local cluster gives you a real control plane for free; creation is the only step that differs from the cloud.

02
Check the node and system pods

Before deploying anything of your own, confirm the cluster itself is healthy. kubectl get nodes shows the machines Kubernetes can schedule onto, and listing pods in kube-system shows the control plane's own workloads. A fresh kind cluster has exactly one node, which is both control plane and worker.

$ kubectl get nodes -o wide
NAME STATUS ROLES AGE VERSION INTERNAL-IP OS-IMAGE lab-control-plane Ready control-plane 2m v1.36.1 172.18.0.2 Debian GNU/Linux 12
$ kubectl get pods -n kube-system
NAME READY STATUS RESTARTS AGE coredns-7c65d6cfc9-8xptd 1/1 Running 0 2m coredns-7c65d6cfc9-vkw92 1/1 Running 0 2m etcd-lab-control-plane 1/1 Running 0 2m kube-apiserver-lab-control-plane 1/1 Running 0 2m kube-controller-manager-lab-control-plane 1/1 Running 0 2m kube-proxy-4zqk7 1/1 Running 0 2m kube-scheduler-lab-control-plane 1/1 Running 0 2m

Those kube-apiserver, etcd, kube-scheduler, and kube-controller-manager pods are the control plane from the architecture diagram, running as static pods on the node. If any of them is not Running, stop here and fix the cluster before continuing, nothing else will work reliably.

Common mistake: forgetting -n kube-system and concluding the cluster is broken because kubectl get pods shows No resources found in default namespace. That just means your namespace is empty, which is expected right now.

Expected output:

$ kubectl get nodes
NAME               STATUS   ROLES
lab-control-plane  Ready    control-plane

What this teaches: System pods are Kubernetes running itself; a healthy cluster shows them all Running before you deploy anything.

03
Run a deployment

A Deployment declares desired replicas; the controller keeps that many pods running. You could create a bare pod with kubectl run, but a bare Pod has no controller behind it: its containers restart according to restartPolicy, yet if the Pod object itself is deleted, or its node dies, nothing recreates it, it is gone forever. A Deployment creates a ReplicaSet, and the ReplicaSet's whole job is to notice a missing pod and replace it, that is the self-healing you will test later. Always wrap workloads in a Deployment (or StatefulSet/DaemonSet) in real life.

kubectl create deployment web --image=nginx:stable --replicas=2
Creates a Deployment named web running two nginx pods. Note that nginx:stable is a floating tag, it moves with new releases, so pin a digest (nginx:stable@sha256:...) in any manifest you intend to reproduce exactly.
deployment.apps/web created

Breaking the command down: web is the Deployment's name (and the app=web label the pods inherit, which you will use for selectors later), --image=nginx:stable is the container image pulled from Docker Hub, and --replicas=2 is the desired count written into etcd. The moment the record exists, the scheduler and ReplicaSet controller act on it, you never touched a node yourself.

Verify: the Deployment reports both replicas available.

$ kubectl get deployment web
NAME READY UP-TO-DATE AVAILABLE AGE web 2/2 2 2 45s

Common mistake: using a tag that does not exist (e.g. nginx:stabel). The Deployment is created happily, the failure only shows up later as ImagePullBackOff on the pods, because the spec itself was valid. The object existing is not proof the workload is running.

Expected output:

$ kubectl create deployment web --image=nginx:stable
deployment.apps/web created

What this teaches: Imperative kubectl create is the fastest way to get a first deployment; declarative manifests come later in this lab.

04
Inspect the cluster

kubectl get is your microscope. With -o wide it adds the node and IP columns; kubectl describe shows the full event history of an object, which is where you look first when something is stuck. Get comfortable reading both before moving on.

$ kubectl get pods -o wide
NAME READY STATUS NODE web-6f...-a1b2 1/1 Running lab-control-plane web-6f...-c3d4 1/1 Running lab-control-plane

The pod names tell a story: web (Deployment) → 6f... (ReplicaSet hash) → a1b2 (random pod suffix). That nesting is the object hierarchy you just created. To see all of it at once:

$ kubectl get all -l app=web
NAME READY STATUS RESTARTS AGE pod/web-6f...-a1b2 1/1 Running 0 3m pod/web-6f...-c3d4 1/1 Running 0 3m NAME READY UP-TO-DATE AVAILABLE AGE deployment.apps/web 2/2 2 2 3m NAME DESIRED CURRENT READY AGE replicaset.apps/web-6f... 2 2 2 3m

Verify: kubectl describe deployment web should end with an event like Scaled up replica set web-6f... to 2. If a pod is stuck, kubectl describe pod web-6f...-a1b2 shows exactly which stage (pulling, scheduling, starting) is failing, with the reason in the Events section at the bottom.

Expected output:

$ kubectl get deployments,pods
NAME                 READY   UP-TO-DATE
deployment.apps/web 2/2     2
NAME                     READY   STATUS
pod/web-7c9c5f6b8d-x1y2z 1/1    Running
pod/web-7c9c5f6b8d-z9a8b 1/1    Running

What this teaches: Inspecting the cluster teaches the object model: deployments own ReplicaSets, ReplicaSets own pods.

05
Scale the deployment up and down

Scaling is just editing the replica count in the desired state, the ReplicaSet controller does the rest. This is the fastest way to feel the reconciliation loop: change a number, watch reality catch up.

kubectl scale deployment web --replicas=4
Declares 4 replicas; two new pods are scheduled immediately.
deployment.apps/web scaled
$ kubectl get pods -l app=web -w
NAME READY STATUS RESTARTS AGE web-6f...-a1b2 1/1 Running 0 5m web-6f...-c3d4 1/1 Running 0 5m web-6f...-e5f6 0/1 ContainerCreating 0 3s web-6f...-g7h8 0/1 ContainerCreating 0 3s web-6f...-e5f6 1/1 Running 0 9s web-6f...-g7h8 1/1 Running 0 9s

The -w (watch) flag streams changes live, press Ctrl+C to stop it. Watching the transition from ContainerCreating to Running is the reconciliation loop made visible. Now scale back down so the self-healing test later stays readable:

kubectl scale deployment web --replicas=2

Common mistake: editing a pod directly to "scale", pods are disposable units, not the place where state lives. Any change you make to a single pod (including deleting it) is overridden by the controller. Always change the Deployment, never the pod.

Expected output:

$ kubectl scale deployment web --replicas=4
deployment.apps/web scaled

What this teaches: Scaling is changing desired state and watching the controllers converge; the speed of convergence is the platform's job.

06
Read logs and exec into a pod

Two commands you will use daily: kubectl logs streams a container's stdout/stderr, and kubectl exec opens a shell inside a running container for live debugging. These work on any pod, in any cluster, with no SSH keys or node access required, one of the biggest operational wins over managing VMs by hand.

$ kubectl logs deploy/web --tail=5
10.244.0.1 - - [13/Feb/2025:14:02:11 +0000] "GET / HTTP/1.1" 200 615 "-" "curl/8.7.1" 2025/02/13 14:02:11 [notice] 1#1: start worker process 29

Using deploy/web instead of a pod name is a handy trick: kubectl picks one of the Deployment's pods for you, so you do not have to copy a random pod suffix. To look around inside the container:

OPEN A SHELL INSIDE A POD

kubectl exec -it deploy/web -- /bin/sh
# inside the container:
cd /usr/share/nginx/html && ls
cat /etc/nginx/nginx.conf | head -20
exit

Expected output:

$ kubectl logs deploy/web --tail=1
/docker-entrypoint.sh: Configuration complete; ready for start up

Verify: while inside, run hostname, it prints the pod name, confirming you are genuinely inside that pod's network namespace. If exec fails with executable file not found, the image is minimal (distroless/scratch) and has no shell; use kubectl debug with an ephemeral container instead.

What this teaches: Logs and exec are the debugging pair: logs show what the app said, exec shows what the app sees.

07
Switch to a declarative manifest

Imperative commands like kubectl create and kubectl scale are fine for learning, but they are not reproducible, nothing records why the cluster looks the way it does. Real teams declare the desired state in YAML, store it in git, and apply it. Kubernetes merges the manifest into the live object, so the file becomes the source of truth. Generate a starting manifest from the running Deployment instead of writing one from scratch:

kubectl get deployment web -o yaml > web-deployment.yaml
Dumps the live Deployment as YAML so you can edit and re-apply it.

Strip the server-managed fields (status:, resourceVersion, uid, creationTimestamp) and you are left with the clean, minimal manifest:

web-deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
  labels:
    app: web
spec:
  replicas: 2
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
        - name: nginx
          image: nginx:stable
          ports:
            - containerPort: 80

Expected output:

$ kubectl apply -f web-deployment.yaml
deployment.apps/web configured

Change replicas to 3 in the file, then apply it:

$ kubectl apply -f web-deployment.yaml
deployment.apps/web configured
$ kubectl get deployment web
NAME READY UP-TO-DATE AVAILABLE AGE web 3/3 3 3 20m

Note the word configured instead of created, apply is idempotent. Run it twice and the second run prints unchanged. That property is what makes manifests safe to keep in CI pipelines.

Common mistake: applying the raw -o yaml dump without removing the status block and server fields. It usually still works, but the file is noisy and some fields cause warnings. Always trim to the minimal spec shown above.

What this teaches: Declarative manifests are versionable desired state; apply becomes the only deployment verb.

08
Watch the rollout history and roll back

Every change to a Deployment's pod template creates a new revision, and kubectl keeps the history. This means a bad image push is a 10-second fix, not an outage: roll back to the previous ReplicaSet and the old pods come back.

$ kubectl set image deployment/web nginx=nginx:1.27
deployment.apps/web image updated
$ kubectl rollout status deployment/web
Waiting for deployment "web" rollout to finish: 1 out of 3 new replicas have been updated... deployment "web" successfully rolled out
$ kubectl rollout history deployment/web
REVISION CHANGE-CAUSE 1 <none> 2 <none> --record

Current kubectl versions deprecated and ignore --record, so CHANGE-CAUSE stays <none>; revision numbers are the reliable identifier. To undo that image change instantly:

kubectl rollout undo deployment/web
Rolls back to the previous revision (nginx:stable).

Verify: kubectl get deployment web -o jsonpath='{.spec.template.spec.containers[0].image}' prints nginx:stable again after the undo finishes.

Expected output:

$ kubectl rollout history deployment/web
REVISION  CHANGE-CAUSE
1         <none>
2         <none>

What this teaches: Revision history makes rollbacks boring, which is exactly what you want from a safety mechanism.

09
Prove self-healing

The defining promise of Kubernetes is that it repairs itself. Do not take it on faith, delete exactly one web pod by name and watch the ReplicaSet replace it. Deleting by name matters here: a label selector such as -l app=web would delete every matching pod at once, which is a different and more destructive test.

POD=$(kubectl get pods -l app=web -o jsonpath='{.items[0].metadata.name}') && kubectl delete pod $POD && kubectl get deploy web

Within seconds the Deployment should report full readiness again, proving self-healing:

pod "web-6f...-a1b2" deleted
$ kubectl get deploy web
NAME READY UP-TO-DATE AVAILABLE AGE web 3/3 3 3 25m
$ kubectl get pods -l app=web
NAME READY STATUS RESTARTS AGE web-6f...-c3d4 1/1 Running 0 25m web-6f...-e5f6 1/1 Running 0 19m web-6f...-j9k0 1/1 Running 0 4s

Read the pod ages carefully: the replacement pod web-6f...-j9k0 is 4 seconds old with a fresh random suffix, while its siblings are untouched. Nobody restarted anything by hand, the ReplicaSet controller saw current=2, desired=3 and created a pod to close the gap. That is the reconciliation loop from the architecture diagram, observed end to end.

What this teaches: self-healing is the ReplicaSet controller reconciling desired versus actual state, no manual intervention required.

Step Flow Map

Each box is one step in the lab; work them in order, since later steps build on earlier state.

Progress So Far

How It Works

Kubernetes is a control plane that drives reality toward declared desired state. You post manifests to the API server; etcd stores them; controllers run reconciliation loops that notice three replicas wanted, two running, and create the third. The control plane is declarative even when you drive it imperatively: commands like create, scale, set image, and delete are imperative conveniences that write desired state to the API server, and the controllers reconcile reality toward it. Every command you run is either declaring state or asking about the gap between desired and actual.

The object hierarchy exists so each layer has one job. A Deployment manages a ReplicaSet, which maintains the pod count across a rolling update; a Pod is one or more containers sharing network and storage. When you change the image, the Deployment creates a new ReplicaSet and scales it up while scaling the old one down, which is why rollouts can proceed without downtime and why rollbacks are just scaling the previous ReplicaSet back up. True zero-downtime also needs a readiness probe so new pods only receive traffic once they actually serve; without one, the rollout can send requests to pods that are still starting.

Local clusters like kind run the whole control plane as containers on your laptop. That makes them perfect for learning: creation and deletion take minutes, mistakes cost nothing, and kubectl works exactly as it does against AKS or EKS. The skills transfer directly; only the cluster provisioning step differs in the cloud.

Testing & Validation

Run this checklist while the cluster still exists. Every item should pass before you tear anything down:

  • kubectl get nodes, node is Ready.
  • kubectl get deploy web, READY equals the replica count you set.
  • kubectl rollout status deploy web, prints successfully rolled out.
  • kubectl describe deploy web, Events show the scale-up and pod recreation, with no failed-pull or crash events.

Every check above has passed, so tear the lab down last. Deleting the Deployment removes its ReplicaSet and pods through owner references, you delete one object and the garbage collector handles the children. Deleting the kind cluster removes the Docker container and its kubectl context. Nothing validates after this point by design:

DELETE WORKLOADS AND THE CLUSTER

kubectl delete -f web-deployment.yaml && kind delete cluster --name lab

Expected output:

$ kubectl delete -f web-deployment.yaml && kind delete cluster --name lab
deployment.apps "web" deleted
Deleted nodes: ["lab-control-plane"]

Verify: docker ps no longer shows a lab-control-plane container, and kubectl config get-contexts no longer lists kind-lab. If you want to keep the cluster for the follow-on labs, skip the kind deletion and only delete the Deployment.

Troubleshooting

  • kubectl cannot connect: kind writes a kubeconfig context; confirm with kubectl config current-context. If it shows a different cluster, switch with kubectl config use-context kind-lab. If the context is missing entirely, the cluster was deleted or the kubeconfig was overwritten, recreate it with kind create cluster --name lab.
  • Pods Pending: the node may lack resources; check kubectl describe pod. The Events section will say Insufficient cpu or 0/1 nodes are available. On kind, the real cause is usually Docker Desktop's memory limit, raise it in Docker Desktop settings, or scale your replicas back down.
  • ImagePullBackOff: the image name or tag is wrong; verify it pulls with docker. Run docker pull nginx:stable locally, if that fails, the tag does not exist. Fix the Deployment with kubectl set image deployment/web nginx=<correct-image>; editing the pod directly will not stick.
  • CrashLoopBackOff: the container starts and immediately exits, and Kubernetes keeps restarting it with backoff. Run kubectl logs <pod> --previous to see the output from the crashed instance, not the currently restarting one, that is where the actual error message lives.
  • kind create cluster fails with a port or name conflict: a previous cluster was not cleaned up. List them with kind get clusters and remove the stale one with kind delete cluster --name <name>. On corporate networks, a VPN that hijacks localhost routing can also break the API-server connection, disconnect it and retry.

Quick-reference for the failures you are most likely to hit in this lab.

SymptomLikely causeFix
Cluster creation failsDocker is not running, or ports are already in useStart the Docker engine and retry; kind needs the daemon healthy first.
kubectl reports no contextKubeconfig was not merged or points elsewhereRun kind get kubeconfig and check kubectl config current-context.
Pod stuck in PendingNo node has capacity, or a volume cannot attachkubectl describe pod names the exact blocker in the Events section.
ImagePullBackOffWrong image name or tag, or a private registry without credentialsVerify the tag exists with docker pull; the error is almost always a typo.
Rollback did not restore behaviorRolled back to the wrong revisionCheck kubectl rollout history first and pick the revision deliberately.

Extension Ideas

  • Expose the deployment in Pods & Deployments, right now the nginx pods are only reachable inside the cluster, and a Service is how you give them a stable endpoint.
  • Add networking and Services in Kubernetes Networking to route external traffic in through an Ingress and restrict pod-to-pod traffic with policy.
  • Move to managed Kubernetes in Azure Kubernetes Service once the kubectl workflow here feels mechanical, the manifests carry over unchanged.
  • Give the containers resource requests and limits (resources: in the manifest) and observe how the scheduler's behaviour changes when the node fills up.

Key Results

  • Created a working local Kubernetes cluster.
  • Ran a 2-replica deployment via kubectl.
  • Observed control-plane reconciliation of desired state.
  • Proved self-healing by deleting and recovering a pod.

Key Takeaways

  • Kubernetes is a desired-state system: you declare what you want in the API server, and controllers reconcile reality toward it, you never manage containers by hand.
  • Always wrap workloads in a Deployment, not a bare pod; the ReplicaSet it creates is what gives you self-healing and scaling.
  • kubectl get for status, kubectl describe for the event trail, kubectl logs for the app's own words, that trio diagnoses almost everything.
  • Imperative commands are for learning; declarative YAML in git is for anything that matters. kubectl apply is idempotent and safe to repeat.
  • The kill-a-pod test is the fastest sanity check that a cluster is healthy: if the replacement appears in seconds, the whole control-plane loop is working.

Practice Challenges

Challenge 1: Expose and curl the app

Goal: Create a Service for the deployment and curl it from your machine.

Hint: Port-forward is the quickest path on kind; a NodePort works too.

Challenge 2: Break it, then roll back

Goal: Deploy a bad image tag, watch the rollout stall on the readiness check, then roll back.

Hint: The stalled rollout is the lesson; rollout status shows it waiting.

Challenge 3: Scale to zero

Goal: Scale the deployment to zero replicas and observe what remains in the cluster.

Hint: The Deployment and ReplicaSet persist; only the pods are gone, which is why scaling back up is instant.