Sscribnet.io
Zero to Dangerous

Kubernetes

Deploy a real app on your own cluster — and understand every object you're creating along the way.

6 chapters~25 min readBeginner
Chapter 1

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.

Tip: If the VMware comparison helps, keep it: a Kubernetes node is roughly an ESXi host, and a pod is roughly a VM — except pods are cattle. You never SSH into a pod to fix it. You delete it and let the manager replace it.
Chapter 2

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.

Footgun: A container's filesystem is ephemeral. Anything your app writes inside the container — uploads, logs, a database file — vanishes when the container is replaced. Chapter 4 fixes this properly, but burn the rule in now: containers are for code, never for data.
Chapter 3

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.

Tip: 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).
Chapter 4

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.

Footgun: 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.
Chapter 5

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
Tip: The decision tree is short. Internal-only? ClusterIP. Quick test on a laptop? NodePort. Cloud with real LBs? LoadBalancer. Homelab or production with hostnames and TLS? Ingress, every time.
Chapter 6

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.

Footgun: k3s's Traefik binds ports 80 and 443 on the node. If something else (another web server, a reverse proxy) already owns those ports on your homelab box, Traefik's pods will crash-loop. Check with ss -tlnp | grep -E ':(80|443)' before you install.

The dangerous checklist

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.