K8S · THE HARD WAY / ALPINE START
START · 4 BARE DEBIAN MACHINES · 13 LABS AHEAD
KUBERNETES THE HARD WAY · THE WHOLE COURSE ON ONE PAGE

Build a Kubernetes cluster from scratch.

Kubernetes The Hard Way in 13 labs: a working cluster on four Debian machines, with every certificate, kubeconfig and systemd unit written by hand. This page maps the whole course — scroll to follow the build in order.

CLICK TO COPY $ git clone --depth 1 https://github.com/kelseyhightower/kubernetes-the-hard-way
13 LABS  ·  93 CONCEPTS  ·  27 COMPONENTS  ·  8 CERTIFICATES  ·  1 CLUSTER
START 4 BARE DEBIAN MACHINES LAB 12 · SMOKE TEST CLEANUP 01 02 03 04 05 06 07 08 09 10 11 12 13
STEP HEIGHT = CONCEPTS PER LAB · MIN 3 · MAX 11 · TOTAL 93
HOVER A DOT ▲ · CLICK TO JUMP TO A LAB
THE FINISHED CLUSTER — EVERY PART ON ONE DIAGRAM

What you’re building

Four machines and every process, file and port that matters. Hover any part to see what feeds it. The three views stack: TRUST shows who holds which certificate, TRAFFIC shows who talks to whom on which port, BUILD stamps each part with the lab that creates it.

VIEWS COMBINE — TURN ON TWO OR ALL THREE
JUMPBOX 1 CPU · 512 MB · 10 GB DEBIAN 12 · BOOKWORM downloads/ client controller worker cni- plugins ≈500 MB · fetched once CA ca.key machines.txt IP FQDN HOST SUBNET single source of truth kubectl ~/.kube/config SERVER server.kubernetes.local 2 GB RAM · 20 GB DISK DEBIAN 12 · BOOKWORM etcd /var/lib/etcd chmod 700 :2379 · client :2380 · peer kube-apiserver tls: ca.crt · kube-api-server.crt/.key SAN 10.32.0.1 · 127.0.0.1 · server.kubernetes.local :6443 kube-controller-manager cluster-cidr 10.200.0.0/16 kube-scheduler leader-elect: true /var/lib/kubernetes ca.crt · kube-api-server.crt/.key · service-accounts.key encryption-config.yaml · *.kubeconfig NODE-0 node-0.kubernetes.local swapoff -a br_netfilter 2 GB RAM · 20 GB DISK DEBIAN 12 · BOOKWORM kubelet webhook authz · maxPods 16 :10250 containerd overlayfs · SystemdCgroup containerd-shim-runc-v2 runc kube-proxy iptables cni0 · bridge 10.200.0.0/24 NODE-1 node-1.kubernetes.local swapoff -a br_netfilter 2 GB RAM · 20 GB DISK DEBIAN 12 · BOOKWORM kubelet webhook authz · maxPods 16 :10250 containerd overlayfs · SystemdCgroup containerd-shim-runc-v2 runc kube-proxy iptables cni0 · bridge 10.200.1.0/24 ssh · scp CN=admin · O=system:masters CN=kube-apiserver CN=system:kube-controller-manager CN=system:kube-scheduler CN=service-accounts CN=system:node:node-0 · O=system:nodes CN=system:node:node-1 · O=system:nodes CN=system:kube-proxy kubectl → :6443 → etcd :2379 Webhook SubjectAccessReview → :10250 ip route add 10.200.0.0/24 via node-0 ip route add 10.200.1.0/24 via node-1 01 01 01 01 02 03 04 05 06 07 08 09 09 10 11 12 13
LAB 01/13 · PREREQUISITES · 5 CONCEPTS

The machines

Four bare Debian 12 machines — no cloud account, no installer, no operator. The jumpbox runs everything with 512MB of RAM.

MachineCPURAMStorageRole
jumpbox1512MB10GBadministration base
server12GB20GBcontrol plane
node-012GB20GBworker · 10.200.0.0/24
node-112GB20GBworker · 10.200.1.0/24
VERIFY — CHECK THE OS
$ cat /etc/os-release
PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
LAB 02/13 · SETTING UP THE JUMPBOX · 8 CONCEPTS

All binaries, downloaded once

The jumpbox gets the CLI tools (wget curl vim openssl git), a clone of the course repo, and every binary the cluster will run — 500+ MB, downloaded once, sorted into client/ controller/ worker/ cni-plugins/, then copied to the other machines over SSH.

CLICK TO COPY
$ wget -q --show-progress \
    -i downloads-$(dpkg --print-architecture).txt
VERIFY — KUBECTL RUNS LOCALLY
$ kubectl version --client
Client Version: v1.32.3
LAB 03/13 · PROVISIONING COMPUTE · 9 CONCEPTS

machines.txt is the whole inventory

One line per machine. This plain text file is the cluster’s entire inventory system — every later lab greps it or while-read loops over it: SSH keys, hostnames, a shared /etc/hosts.

machines.txt
SCHEMA — IPV4_ADDRESS · FQDN · HOSTNAME · POD_SUBNET
XXX.XXX.XXX.XXX server.kubernetes.local server
XXX.XXX.XXX.XXX node-0.kubernetes.local node-0 10.200.0.0/24
XXX.XXX.XXX.XXX node-1.kubernetes.local node-1 10.200.1.0/24
CLICK TO COPY
$ while read IP FQDN HOST SUBNET; do
    ssh-copy-id root@${IP}
  done < machines.txt
VERIFY — NAME-BASED ACCESS, NO MORE IPS
$ while read IP FQDN HOST SUBNET; do ssh -n root@${HOST} hostname; done < machines.txt
server
node-0
node-1
LAB 04/13 · CERTIFICATE AUTHORITY · 8 CONCEPTS

One CA, eight certificates

You become your own certificate authority: ca.key + ca.crt, RSA 4096, ten years. One bash for-loop over an 8-element array creates every identity in the cluster from ca.conf sections.

ca.key + ca.crt RSA 4096 · 10 YR admin node-0 node-1 kube-proxy kube-scheduler kube-controller-manager kube-api-server service-accounts
subject = CN=system:node:node-0, O=system:nodes

The certificate is the identity. A node’s Kubernetes username and group live in the cert subject; the Node Authorizer reads them from the TLS handshake.

VERIFY — READ THE IDENTITY BACK
$ openssl x509 -in node-0.crt -noout -subject
subject=CN = system:node:node-0, O = system:nodes
LAB 05/13 · KUBECONFIGS · 7 CONCEPTS

Cluster + credentials + context, in one file

Six kubeconfigs, one per client, all --embed-certs=true so each file is self-contained. Every kubeconfig points at server.kubernetes.local:6443 — except admin, which uses 127.0.0.1:6443 because it runs on the server itself.

CLICK TO COPY
$ kubectl config set-cluster kubernetes-the-hard-way \
    --certificate-authority=ca.crt --embed-certs=true \
    --server=https://server.kubernetes.local:6443 \
    --kubeconfig=node-0.kubeconfig
VERIFY — SIX FILES, SIX IDENTITIES
$ ls *.kubeconfig
admin.kubeconfig                     kube-scheduler.kubeconfig
kube-controller-manager.kubeconfig   node-0.kubeconfig
kube-proxy.kubeconfig                node-1.kubeconfig
LAB 06/13 · DATA ENCRYPTION · 5 CONCEPTS

A 32-byte key encrypts Secrets at rest

head -c 32 /dev/urandom | base64 generates key1 for the aescbc provider (with identity fallback). The key goes into an EncryptionConfiguration the API server loads at startup — from then on, Secrets are written to etcd as ciphertext.

CLICK TO COPY
$ export ENCRYPTION_KEY=$(head -c 32 /dev/urandom | base64)
$ envsubst < configs/encryption-config.yaml \
    > encryption-config.yaml
VERIFY — THE KEY IS IN THE CONFIG
$ grep -A1 'name: key1' encryption-config.yaml
- name: key1
  secret: 1oR4hQXUnFhKW6uRLKPyFyVvxrHiPrqPbHKI3EX7g1o=
LAB 07/13 · BOOTSTRAPPING ETCD · 8 CONCEPTS

etcd starts

The first process to run. Kubernetes components are stateless — etcd is the single source of cluster state, one member on the server. chmod 700 /var/lib/etcd, and the kube-api-server cert pair is reused for etcd’s TLS.

2379CLIENT PORT
2380PEER PORT
700CHMOD · DATA DIR
VERIFY — ONE MEMBER, ONE LINE
$ etcdctl member list
6702b0a34e2cfd39, started, controller, http://127.0.0.1:2380, http://127.0.0.1:2379, false
LAB 08/13 · THE CONTROL PLANE · 9 CONCEPTS

The control plane comes up

Three systemd units land on the server — kube-apiserver, kube-controller-manager, kube-scheduler — fed by everything staged so far: certs, kubeconfigs, key1, etcd. Then an RBAC ClusterRole (system:kube-apiserver-to-kubelet) lets the API server reach the kubelets — that’s what makes logs, exec and metrics work.

CLICK TO COPY
$ curl --cacert ca.crt https://server.kubernetes.local:6443/version
RESPONSE FROM :6443
{
  "major": "1",
  "minor": "32",
  "gitVersion": "v1.32.3",
  "goVersion": "go1.23.6",
  "platform": "linux/arm64"
}

TLS signed by your own CA. From here on, every kubectl command goes through this endpoint.

VERIFY — ANONYMOUS CURL OVER TLS
$ kubectl cluster-info --kubeconfig admin.kubeconfig
Kubernetes control plane is running at https://127.0.0.1:6443
LAB 09/13 · WORKER NODES · 11 CONCEPTS · THE BIGGEST LAB

runc → CNI → containerd → kubelet → kube-proxy

Each worker gets the full container stack plus OS prep: swapoff -a (the kubelet refuses to start with swap on by default), the br-netfilter module with bridge-nf-call-iptables=1, and socat conntrack ipset kmod — socat is what makes kubectl port-forward work in lab 12. One sed stamps each node’s identity into the network config: the literal word SUBNET becomes 10.200.0.0/24 on node-0 and 10.200.1.0/24 on node-1.

CLICK TO COPY
$ SUBNET=$(grep node-0 machines.txt | cut -d " " -f 4)
$ sed "s|SUBNET|${SUBNET}|g" configs/10-bridge.conf \
    > 10-bridge.conf
CLICK TO COPY
$ systemctl enable --now containerd kubelet kube-proxy
VERIFY — BOTH NODES REGISTER
$ kubectl get nodes --kubeconfig admin.kubeconfig
NAME     STATUS   ROLES    AGE   VERSION
node-0   Ready    <none>   35s   v1.32.3
node-1   Ready    <none>   10s   v1.32.3
LAB 10/13 · REMOTE ACCESS · 5 CONCEPTS

kubectl works from the jumpbox

The same four kubectl config commands as lab 5, without --kubeconfig — so the result lands in the default ~/.kube/config. The endpoint server.kubernetes.local:6443 resolves through the /etc/hosts entries from lab 3.

CLICK TO COPY
$ kubectl config set-cluster kubernetes-the-hard-way \
    --certificate-authority=ca.crt --embed-certs=true \
    --server=https://server.kubernetes.local:6443
$ kubectl config set-credentials admin \
    --client-certificate=admin.crt --client-key=admin.key
$ kubectl config set-context kubernetes-the-hard-way \
    --cluster=kubernetes-the-hard-way --user=admin
$ kubectl config use-context kubernetes-the-hard-way
VERIFY — NO FLAGS NEEDED
$ kubectl get nodes
NAME     STATUS   ROLES    AGE   VERSION
node-0   Ready    <none>   2m    v1.32.3
node-1   Ready    <none>   2m    v1.32.3
LAB 11/13 · POD NETWORK ROUTES · 6 CONCEPTS

Cross-node pod traffic: four static routes

Pods on different nodes can’t reach each other yet — nothing maps a pod CIDR to the node that owns it. Four ip route add commands across the three machines (IPs and subnets grepped from machines.txt) fix that. Pod networking here is plain Linux routing.

CLICK TO COPY
server$ ip route add 10.200.0.0/24 via ${NODE_0_IP}
server$ ip route add 10.200.1.0/24 via ${NODE_1_IP}
node-0$ ip route add 10.200.1.0/24 via ${NODE_1_IP}
node-1$ ip route add 10.200.0.0/24 via ${NODE_0_IP}
VERIFY — THE ROUTES EXIST
$ ssh root@server ip route
default via XXX.XXX.XXX.XXX dev ens160
10.200.0.0/24 via XXX.XXX.XXX.XXX dev ens160
10.200.1.0/24 via XXX.XXX.XXX.XXX dev ens160
LAB 12/13 · SMOKE TEST · 9 CONCEPTS

Verify encryption, networking, services

Six checks against the running cluster: hexdump a Secret straight out of etcd (the prefix shows the aescbc key at work), then a Deployment, port-forward 8080→80 (that’s socat from lab 9), logs, exec, and a NodePort service.

$ etcdctl get /registry/secrets/default/kubernetes-the-hard-way | hexdump -C
00000040  6b 38 73 3a 65 6e 63 3a  61 65 73 63 62 63 3a 76  |k8s:enc:aescbc:v|
00000050  31 3a 6b 65 79 31 3a 9b  61 3f 65 21 ad a0 8f 15  |1:key1:.a?e!....|
CLICK TO COPY
$ kubectl create deployment nginx --image=nginx:latest
$ kubectl expose deployment nginx --port 80 --type NodePort
VERIFY — NGINX ANSWERS ON THE NODE PORT
$ curl -I http://node-0:${NODE_PORT}
HTTP/1.1 200 OK
Server: nginx/1.27.4
LAB 13/13 · CLEANING UP · 3 CONCEPTS
Delete the four VMs
to complete the teardown.

Nothing exists outside the four machines — no cloud resources, no external state. Everything the course created was files and processes, and both go away with the VMs. The diagram on the right clears accordingly.

VERIFY — NOTHING LEFT
$ # no teardown commands — deleting the VMs removes everything
END OF COURSE · 13/13 LABS
AFTER LAB 13 — EVERYTHING YOU INSTALLED

All 27 components

JUMPBOX ×5
kubectlopenssl · CA + certsSSH / scpmachines.txtenvsubst
SERVER ×8
etcdetcdctlkube-apiserverkube-controller-managerkube-schedulerEncryptionConfigurationRBACNode Authorizer
NODE-0 · NODE-1 ×12
kubeletkube-proxycontainerdcontainerd-shim-runc-v2runccrictlCNI pluginscni0 bridgebr-netfiltersocat · conntrack · ipsetiptablesnginx
EVERYWHERE ×2
systemd/etc/hosts