Kubernetes Basics: Your First Cluster and Pod
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
kindorminikubeandkubectlinstalled. - 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:
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
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 labkind 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.
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.
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.
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.
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=2nginx: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.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.
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.
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.
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:
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.
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=4The -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=2Common 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.
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.
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.
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.yamlStrip 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:
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.
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.
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/webVerify: 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.
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 webWithin seconds the Deployment should report full readiness again, proving self-healing:
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 isReady.kubectl get deploy web,READYequals the replica count you set.kubectl rollout status deploy web, printssuccessfully 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 labExpected 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 withkubectl config use-context kind-lab. If the context is missing entirely, the cluster was deleted or the kubeconfig was overwritten, recreate it withkind create cluster --name lab. - Pods Pending: the node may lack resources; check
kubectl describe pod. The Events section will sayInsufficient cpuor0/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:stablelocally, if that fails, the tag does not exist. Fix the Deployment withkubectl 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> --previousto 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 clustersand remove the stale one withkind 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.
| Symptom | Likely cause | Fix |
|---|---|---|
| Cluster creation fails | Docker is not running, or ports are already in use | Start the Docker engine and retry; kind needs the daemon healthy first. |
kubectl reports no context | Kubeconfig was not merged or points elsewhere | Run kind get kubeconfig and check kubectl config current-context. |
| Pod stuck in Pending | No node has capacity, or a volume cannot attach | kubectl describe pod names the exact blocker in the Events section. |
| ImagePullBackOff | Wrong image name or tag, or a private registry without credentials | Verify the tag exists with docker pull; the error is almost always a typo. |
| Rollback did not restore behavior | Rolled back to the wrong revision | Check 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 getfor status,kubectl describefor the event trail,kubectl logsfor 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 applyis 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.