Kubernetes
Deploy a real app on your own cluster — and understand every object you're creating along the way.
Why Kubernetes exists
You already know this story if you've ever managed VMware. You had a physical server. You carved it into VMs, and every VM was a pet: you named it, you nursed it through patches, you held your breath during vMotion. Then the pets multiplied. Ten servers became a hundred VMs, and the spreadsheet of "which VM runs what, and why is DB03 doing 90% CPU at 2am" became somebody's full-time job.
Kubernetes is what happens when you stop managing individual machines and start managing intent. You don't say "put this app on host 4." You say "I want three copies of this app running at all times," and a control plane figures out the placement, restarts the dead ones, and rolls out updates without you babysitting a maintenance window.
The mental model that actually sticks
Map it to the world you know. A VM is a heavy apartment building: full kitchen, plumbing, its own foundation (a complete guest OS), takes minutes to build, and eats gigabytes of disk before anything useful runs inside it. A container is a furnished room inside a building that already exists: it shares the foundation (the host's kernel), starts in milliseconds, and only carries what's actually different — the app and its dependencies.
Kubernetes is the building manager. It doesn't care about any individual room. It cares that the building has the right number of occupied rooms, that broken rooms get replaced, that new tenants move in without anyone noticing, and that the mail (network traffic) reaches the right door. You file requests with the manager ("three replicas, please"); the manager does the physical labor.
Why "just use Docker" stops scaling
Docker is excellent at running one container on one machine. The moment you need the same app on three machines, Docker shrugs — that's not its job. Who decides which machine has spare capacity? Who restarts the container when the host dies? Who updates all three copies without downtime, and rolls back when the new version is broken? Who hands out IP addresses that don't change every time a container restarts?
Answering those questions by hand is how you end up writing a pile of shell scripts and cron jobs that slowly becomes a worse Kubernetes written by you, at 2am, during an outage. The industry already made that mistake at scale so you don't have to.
- Kubernetes manages intent ("three copies, always"), not machines
- VM = apartment building; container = furnished room; Kubernetes = the building manager
- Pets get names and hand-holding; cattle get replaced when they fail
- "Just Docker" breaks at scheduling, self-healing, and networking across machines
Containers in 10 minutes
You can't survive Kubernetes without understanding the thing it orchestrates. The good news: containers are a simpler idea than the marketing suggests. A container image is a frozen filesystem snapshot plus a startup command. When you run it, the Linux kernel gives that process its own walled-off view of the world — its own files, its own network interface, its own process list — using two old kernel features called namespaces and cgroups. That's it. No virtualization, no guest OS, no magic. Isolation enforced by the kernel, not by hardware.
Images are layered
Images are built in layers, and layers are the whole reason containers are fast. Each instruction in a Dockerfile creates one read-only layer stacked on the last. When ten containers share the same base layer, the disk and memory store it once. When you change one line at the bottom of your Dockerfile, only the layers below that line rebuild.
Here's the anatomy of a real Dockerfile — read it top to bottom and you've learned 80% of what matters:
# Start FROM a known base — this is layer 1
FROM nginx:1.27
# Bake config into the image — a new layer each
COPY ./site.conf /etc/nginx/conf.d/default.conf
COPY ./html /usr/share/nginx/html
# Document the port (informational only — it opens nothing by itself)
EXPOSE 80
# The process this container runs. If it exits, the container exits.
CMD ["nginx", "-g", "daemon off;"]
Build it with docker build -t my-site:1.0 . and run it with docker run -p 8080:80 my-site:1.0. The image is the recipe; the container is the meal. Images are immutable — you never change a running container, you build a new image and replace it. That immutability is exactly why Kubernetes can treat workloads as cattle.
Registries are just image warehouses
An image on your laptop is useless to a cluster. A registry is where images live so machines can pull them — Docker Hub for public images, or a private registry for yours. When Kubernetes needs to run your app on some node, that node pulls the image from the registry by name and tag (my-site:1.0). Tag immutability matters here: if you keep pushing new code to the latest tag, nodes cache unpredictably and you can't tell what's actually running. Tag every build with a version or git hash. latest is a footgun wearing a trench coat.
- Image = frozen filesystem + startup command; container = a running instance of an image
- Layers make builds fast and sharing cheap — order your Dockerfile from slow-changing to fast-changing
- Registries distribute images; tag every build with a real version, never rely on
latest - Container filesystems are ephemeral — plan for that before you deploy anything stateful
The three objects that run everything
Kubernetes has dozens of object types, but three of them do 90% of the work: Pods, Deployments, and Services. Learn what each one is for and the rest of the API starts reading like a menu instead of a maze.
Pod: the smallest unit
A Pod is one or more containers that share a network namespace and storage — they get one IP address and can talk to each other on localhost. Think of a pod as a single VM that happens to contain tightly coupled processes (your app plus a logging sidecar, say). You almost never create pods directly, because a pod that dies stays dead — nothing restarts it. Pods are the currency the other objects spend, not something you manage by hand.
Deployment: the "keep this many running" machine
A Deployment is where intent lives. You declare "I want 2 replicas of this pod template," and the Deployment's controller watches reality and reconciles it toward your declaration, forever. Container crashed? Replacement pod. Node died? Pods rescheduled elsewhere. New image version? Rolling update — new pods come up, old pods drain, zero downtime if your app handles it. Broken new version? kubectl rollout undo and you're back.
apiVersion: apps/v1
kind: Deployment
metadata:
name: whoami
labels:
app: whoami
spec:
replicas: 2
selector: # which pods belong to me...
matchLabels:
app: whoami
template: # ...and what those pods look like
metadata:
labels:
app: whoami # must match the selector above
spec:
containers:
- name: whoami
image: traefik/whoami:latest
ports:
- containerPort: 80
The critical line is the selector / template.metadata.labels handshake: the Deployment finds its pods by label. If those labels don't match, your Deployment cheerfully reports zero replicas and you stare at an empty kubectl get pods wondering what you did wrong. Everyone hits this once.
Service: the stable front door
Pods are mortal — they get replaced and every replacement gets a new IP. A Service is a stable virtual IP (and DNS name) that load-balances across the current set of healthy pods matching its selector. Your app talks to whoami the Service; Kubernetes worries about which pods are behind it right now.
apiVersion: v1
kind: Service
metadata:
name: whoami
spec:
selector:
app: whoami # send traffic to pods with this label
ports:
- port: 80 # the Service's port...
targetPort: 80 # ...forwarded to this port on the pods
Apply both files with kubectl apply -f whoami.yaml and the chain is complete: Deployment keeps 2 pods alive, Service gives them one stable address. Inside the cluster, any other pod can now reach your app at http://whoami — Kubernetes DNS resolves the Service name automatically.
kubectl get pods, kubectl describe pod/<name>, and kubectl logs <name> are the holy trinity of debugging. Nine out of ten beginner problems are answered by the Events section at the bottom of describe — usually ImagePullBackOff (bad image name) or CrashLoopBackOff (the app exits immediately).- Pod = one or more containers sharing an IP; mortal, never managed directly
- Deployment = "keep N copies of this pod template running," plus rolling updates and rollbacks
- Service = stable IP + DNS name load-balancing across pods matched by label selector
- Labels are the wiring — selector and pod labels must match or nothing connects
Config, secrets, and storage
Chapter 2's rule — containers are for code, never for data — now needs its full solution. Real apps need three things that don't belong baked into an image: configuration that changes per environment, credentials that must stay private, and data that must survive pod restarts. Kubernetes gives you one object for each.
ConfigMaps: configuration as an object
A ConfigMap is a bag of key-value pairs (or whole config files) stored in the cluster, injected into pods as environment variables or mounted files. The point: the same image runs in dev, staging, and prod, and only the ConfigMap changes. Twelve-factor app doctrine, enforced by the platform.
apiVersion: v1
kind: ConfigMap
metadata:
name: whoami-config
data:
WHOAMI_NAME: "homelab"
Reference it in the pod template with envFrom: [{configMapRef: {name: whoami-config}}], and every key becomes an environment variable. Change the ConfigMap, restart the pods, new config — no rebuild.
Secrets: credentials, with a famous caveat
Secrets work like ConfigMaps but are intended for passwords, tokens, and keys. Create one imperatively and skip hand-writing YAML:
kubectl create secret generic db-creds \
--from-literal=username=labuser \
--from-literal=password='s3cr3t-pw'
Now the caveat everyone learns the embarrassing way: Secrets are base64-encoded, not encrypted. Anyone who can read the Secret object can decode it in one command (kubectl get secret db-creds -o jsonpath='{.data.password}' | base64 -d). Base64 is an envelope, not a safe. For a homelab that's usually fine — it keeps credentials out of your YAML and your git history. For anything that matters, you encrypt secrets at rest (etcd encryption) or use an external secret manager. Never commit a Secret manifest with real values to a public repo.
Volumes and PVCs: data that outlives pods
A volume is storage attached to a pod. A PersistentVolumeClaim (PVC) is a pod's request for storage: "I need 5 GB, writable by one node." The cluster's storage provisioner fulfills the claim with an actual volume. This two-step dance exists so your YAML doesn't care whether the storage is a local disk, NFS, or cloud block storage — the claim is portable, the provisioner is environment-specific.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: app-data
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 5Gi
On k3s, the built-in local-path provisioner automatically fulfills this claim using disk on whichever node the pod lands on — no setup required. Mount it in the pod template under volumes/volumeMounts, and files written to that path survive pod restarts, reschedules, and image updates. Delete the PVC and the data is gone — which is exactly the semantics you want: data lifecycle managed as deliberately as the data itself.
local-path storage is tied to one node. If that node dies, the data is unreachable until the node returns. Fine for a homelab learning cluster; for anything you care about, that's what replicated storage (Longhorn, in chapter 6's "where next") is for.- ConfigMaps hold non-secret config; same image, different config per environment
- Secrets hold credentials but are base64, not encryption — keep real values out of git
- PVCs are portable storage requests; the provisioner decides what backs them
- k3s ships a
local-pathprovisioner, so PVCs work out of the box
Getting traffic in
This is the chapter that breaks every beginner, because Kubernetes offers four ways to expose an app and the names explain nothing. Here's the whole thing in one mental picture: your Service from chapter 3 is reachable inside the cluster. Getting traffic from outside is a separate problem, and the four Service/Ingress types are four answers to it.
The four doors, plainly
ClusterIP (the default) exposes the Service on an internal-only IP. Only pods inside the cluster can reach it. This is what you want for backends — your API server should not be on the internet, full stop.
NodePort opens the same port on every node's IP, in the 30000–32767 range, forwarding to your Service. It works everywhere with zero extra components, which is why tutorials love it — and why you should outgrow it fast. Random high ports, no hostnames, no TLS termination. It's the "it works on my machine" of networking.
LoadBalancer asks the infrastructure for a real external load balancer with its own IP. On AWS or Azure that's a cloud LB provisioned automatically. On your homelab there is no cloud, so this type just sits there <pending> forever unless you install something like MetalLB to fake it. Beginners on bare metal burn hours on this. Now you won't.
Ingress is the grown-up answer: a single entry point that routes by hostname and path. One IP, many apps — app1.test and app2.test both hit the same address, and the Ingress controller reads the Host header and forwards to the right Service. It also terminates TLS, so your apps don't each reinvent certificates. An Ingress is just a set of routing rules; the Ingress controller (Traefik ships built into k3s) is the software that enforces them.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: whoami
spec:
ingressClassName: traefik # k3s's built-in controller
rules:
- host: whoami.test
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: whoami # route to the Service from chapter 3
port:
number: 80
- ClusterIP = internal only; NodePort = every node, random high port; LoadBalancer = needs a cloud (or MetalLB)
- Ingress = one entry point routing by hostname/path, with TLS termination — the standard answer
- k3s includes the Traefik Ingress controller out of the box
Your first cluster: k3s in the homelab
Theory's done. k3s is Kubernetes with the batteries included and the weight removed — a single 100 MB binary, SQLite instead of etcd by default, Traefik and the local-path provisioner preinstalled. It's the fastest path from zero to a real cluster on hardware you already own.
Install k3s and connect kubectl
On your first node (any reasonably modern Linux box or VM):
curl -sfL https://get.k3s.io | sh -
sudo k3s kubectl get nodes
You should see one node, Ready, a minute or so later. That command is your proof of life. (Want worker nodes later? Run the same installer on each with K3S_URL and K3S_TOKEN set from /var/lib/rancher/k3s/server/node-token on the server — but one node is plenty for this chapter.)
k3s writes its admin kubeconfig to /etc/rancher/k3s/k3s.yaml on the server. Copy it to your workstation as labuser and point it at the server's real address (example: 192.0.2.10):
scp labuser@192.0.2.10:/etc/rancher/k3s/k3s.yaml ~/.kube/config
sed -i 's/127.0.0.1/192.0.2.10/' ~/.kube/config
chmod 600 ~/.kube/config
kubectl get nodes
Learn five commands and you can operate the cluster: get (list things), describe (why is it broken), logs (what did it say), exec -it (get a shell inside), apply -f (make the cluster match this file). Everything else is specialization.
Deploy the whoami demo
whoami is a tiny web server that reports its own hostname — perfect for proving that load balancing and Ingress actually work. Save the Deployment, Service, and Ingress from chapters 3 and 5 into whoami.yaml, then:
kubectl apply -f whoami.yaml
kubectl get pods -w # watch them come up; Ctrl-C when Running
kubectl get ingress whoami # note the ADDRESS column
No DNS in the homelab? Fake the hostname with a curl header — Traefik routes on the Host header, exactly like real DNS would:
curl -H "Host: whoami.test" http://192.0.2.10/
You'll get back the pod's hostname. Now scale it and watch the load balancer do its job:
kubectl scale deployment whoami --replicas=3
for i in 1 2 3 4; do curl -s -H "Host: whoami.test" http://192.0.2.10/ | grep Hostname; done
Different hostnames in the responses: three pods, one address, traffic spread across them. That loop you just ran is the entire value proposition of chapter 1, in four lines.
ss -tlnp | grep -E ':(80|443)' before you install.The dangerous checklist
- Install k3s on a homelab node and confirm
kubectl get nodesshows it Ready - Deploy an app from your own YAML: Deployment + Service + Ingress, applied with
kubectl apply -f - Scale it to 3 replicas and prove traffic spreads across pods with repeated curls
- Do a zero-downtime update: change the image tag,
kubectl apply, watch the rolling update withkubectl rollout status - Break it on purpose: delete a pod with
kubectl delete podand watch the Deployment replace it — then checkkubectl describeto see the Events telling the story
Where next: you now understand the core loop. The natural next rungs are Helm (packages for Kubernetes — install complex apps like databases with one command instead of fifty YAML files), Longhorn (replicated block storage across nodes, so a dead node doesn't take your data with it — the answer to chapter 4's footgun), and ArgoCD (GitOps: your cluster continuously reconciles itself toward what's committed in git, so the repo becomes the source of truth). Each one is a "Zero to Dangerous" chapter in your homelab story waiting to happen.
Official docs, for when you're ready to go deeper: kubernetes.io/docs and docs.k3s.io are genuinely excellent — start with the Concepts section, not the Tasks.