# How to Install Kubernetes with kubeadm: A 3-Node Cluster, Step by Step

* * *

If you've only ever touched Kubernetes through a managed service like GKE, EKS, or AKS, there's a particular kind of clarity that comes from building a cluster with your own hands — one command at a time, watching it fail, then figuring out why. In this guide, we'll build a real cluster from scratch using **kubeadm**: one control-plane node and two worker nodes, wired together ourselves.

Nothing here is tied to a specific platform. Whether your three nodes are VMs on Proxmox, VirtualBox, or VMware, instances on AWS EC2 or a GCP VM, three Multipass instances on your laptop, or even three VMs sharing one physical machine — the steps are the same, because kubeadm only cares that the nodes can reach each other over the network and each has a working Linux install. By the end, you'll have a working three-node cluster, a real understanding of *why* each step matters (not just what to paste into your terminal), and a troubleshooting section for the handful of things that trip almost everyone up on their first try.

Let's get into it.

## What you'll need

| Node | Hostname | Suggested specs | Role |
| --- | --- | --- | --- |
| Node 1 | `master` | 2 vCPU, 4 GB RAM, 20 GB disk | Control plane |
| Node 2 | `worker1` | 2 vCPU, 4 GB RAM, 20 GB disk | Worker |
| Node 3 | `worker2` | 2 vCPU, 4 GB RAM, 20 GB disk | Worker |

*   Three VMs running any systemd-based Linux distro, each with a static (or DHCP-reserved) IP — this guide is written against **Ubuntu 24.04 LTS**, but nothing in it is Ubuntu-only (see the callout just below)
    
*   All three can reach each other over the network, and you have `sudo` access on each
    
*   Outbound internet access from every node, to download packages and binaries
    
*   Kubernetes **v1.36**, containerd as the runtime, and Calico as the pod network (more on why below)
    

One quick note on the runtime: Docker Engine was retired as a supported kubelet backend a while back (dockershim is gone for good), so we'll install **containerd** directly. That's what most managed Kubernetes services run underneath anyway, so you're not taking a shortcut here — you're doing it the way it's actually done in production.

> **On a distro other than Ubuntu?** Almost everything in this guide works completely unchanged on Fedora, RHEL, Rocky, Alma, Debian — anything running systemd. containerd and kubeadm/kubelet/kubectl are installed via `curl` straight from their official binaries (Steps 2 and 3) specifically so there's no package-manager branching to maintain. The one spot that's still Ubuntu/Debian-flavored is the `ufw` firewall commands near the end — swap those for `firewall-cmd` if you're on a `firewalld` distro; there's a note right there when you get to it.

> **If any of your nodes were cloned or duplicated from the same source** — a template, a snapshot, a shared disk image, or a VM you copied instead of creating fresh — hold that thought until Step 1b below. Cloned machines can silently share an identity with each other in a way that has nothing to do with their hostname or IP, and it's one of the more confusing bugs to debug blind.

## Architecture overview

![](https://cdn.hashnode.com/uploads/covers/641aa341e592dd06478dfa43/ac4efd99-e83c-475f-ab2a-0f4307cc6e38.png align="center")

The control-plane node is the brains of the operation: it runs the API server, `etcd` (where all cluster state lives), the scheduler, and the controller manager. The two worker nodes are where your actual applications run, each managed locally by `kubelet` and `kube-proxy`. Once everything's joined, all three nodes talk to each other over a pod network that Calico sets up for us — we install that right after the control plane comes online.

With the theory out of the way, let's start prepping the machines.

## Step 1 — Prep every node

Everything in this step runs **on all three VMs** — master, worker1, and worker2 — unless noted otherwise.

### Set a unique hostname (all 3 VMs)

> Optional, but worth doing. Plenty of setups already give each node a distinct hostname automatically — a fresh EC2 or GCP instance, a Multipass VM, a manual install where you picked the name at setup. If yours already differ, skip this. It's included here because a clear, memorable hostname per node makes every later step — and every future `ssh` session — noticeably less error-prone.

```bash
# on the master
sudo hostnamectl set-hostname master

# on worker1
sudo hostnamectl set-hostname worker1

# on worker2
sudo hostnamectl set-hostname worker2
```

### Find each node's IP address (all 3 VMs)

Every VM gets its own IP, assigned by whatever's handing out addresses on your network (your router, your cloud provider's VPC, Proxmox's bridge, and so on) — there's no way for a guide to know it in advance, so you'll need to check it on each node yourself:

```bash
hostname -I
```

This prints one or more addresses; pick the one on the network the other two nodes can actually reach (for a cloud VM, that's usually the private IP, not the public one). Run it on `master`, `worker1`, and `worker2`, and write down all three — you'll need them for the next step and again in Step 4.

### Add all nodes to /etc/hosts (all 3 VMs)

This step is a convenience, not a cluster requirement — it just lets you `ssh worker1` instead of `ssh 192.168.1.11`, and makes later commands easier to read. Every command from Step 4 onward in this guide uses each node's **IP address directly**, on purpose, so nothing about the cluster itself depends on these names resolving correctly. If you skip this step, or your VMs' actual hostnames are something other than `master`/`worker1`/`worker2` (very common — cloud instances and Proxmox templates often name themselves `k8s-cp`, `node-1`, etc.), that's completely fine and nothing below will break.

`192.168.1.10` / `.11` / `.12` below are placeholders standing in for whatever three IPs you just wrote down — swap them for your real values. Run this on **all three** nodes:

```bash
cat <<EOF | sudo tee -a /etc/hosts
192.168.1.10 master
192.168.1.11 worker1
192.168.1.12 worker2
EOF
```

### Step 1b — Fix duplicate machine-id and product\_uuid (cloned nodes only)

> This one isn't optional if any of your nodes came from cloning or copying an existing VM or disk image — a hypervisor "clone" action (Proxmox, VirtualBox, VMware, KVM), a snapshot you spun into a new VM, or a raw disk you duplicated by hand. Skip it if every node was installed or launched fresh. Most cloud VMs (a new EC2 instance from a public AMI, a new GCP VM from a public image, a `multipass launch`) are fine by default, because cloud-init regenerates these identifiers on first boot — but it costs ten seconds to check, and it's worth it if you're not certain.

Here's the gotcha: when a VM is cloned from an existing one at the disk level rather than freshly installed, the clone often inherits the *exact same* `/etc/machine-id` and DBus machine ID as the source. Kubernetes uses these — along with the DMI `product_uuid` — as part of how it tells nodes apart internally. If two of your nodes secretly share an ID, you'll see bizarre, hard-to-diagnose symptoms later: a worker that never fully joins, or one node's status silently overwriting another's. It has nothing to do with hostname or IP, so it's easy to stare right past it.

Check for duplicates first, from any node that can reach the others over SSH (swap in your actual hostnames or IPs here if you skipped the `/etc/hosts` step above):

```bash
for h in master worker1 worker2; do
  echo "$h: $(ssh $h sudo cat /etc/machine-id)"
done
```

If any two lines match, regenerate the machine-id on the affected nodes:

```bash
sudo rm -f /etc/machine-id /var/lib/dbus/machine-id
sudo systemd-machine-id-setup
sudo ln -sf /etc/machine-id /var/lib/dbus/machine-id
```

A reboot afterward isn't strictly required, but it's the easiest way to be sure nothing cached the old ID.

### Disable swap (all 3 VMs)

Run this on **every node — the control plane and both workers**, not just the master. Kubernetes expects to fully own memory management on each node. A kubelet that detects active swap will refuse to start cleanly — you *can* override this with `--fail-swap-on=false`, but that's a workaround for a problem, not a fix, and it leads to unpredictable pod evictions later. Better to just turn swap off.

```bash
sudo swapoff -a
sudo sed -i '/ swap / s/^/#/' /etc/fstab
```

The `sed` command comments out the swap line in `/etc/fstab`, so swap stays off across reboots too.

> **Heads up if swap keeps coming back after a reboot:** Ubuntu 22.04/24.04 server images sometimes ship with **zram-based swap**, managed by `systemd-zram-generator` rather than a plain `/etc/fstab` entry. In that case, the commands above turn swap off *right now* but won't stop it from reappearing on the next boot, because there's no fstab line to comment out. Check for it and disable it properly if it's present:
> 
> ```bash
> systemctl status swap-create@zram0.service 2>/dev/null
> # if it exists and is active:
> sudo systemctl mask swap-create@zram0.service
> sudo apt remove -y zram-config 2>/dev/null || true
> ```

### Load required kernel modules (all 3 VMs)

```bash
cat <<EOF | sudo tee /etc/modules-load.d/k8s.conf
overlay
br_netfilter
EOF

sudo modprobe overlay
sudo modprobe br_netfilter
```

`overlay` is what containerd's snapshotter relies on to layer container images efficiently, and `br_netfilter` lets the kernel apply iptables rules to bridged traffic. Skip this and pod-to-pod networking across nodes breaks in ways that are genuinely annoying to trace back to the cause.

### Set required sysctl params (all 3 VMs)

```bash
cat <<EOF | sudo tee /etc/sysctl.d/k8s.conf
net.bridge.bridge-nf-call-iptables  = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward                 = 1
EOF

sudo sysctl --system
```

`ip_forward = 1` is the setting people most often forget — without it, a worker node simply can't route traffic between pods living on different nodes, and you'll be left wondering why two pods that are both clearly `Running` can't talk to each other.

### Sync the clock (all 3 VMs)

> Also not optional, and easy to overlook because it rarely fails loudly. Skip it and you may not notice anything's wrong for weeks — until it is.

Kubernetes leans heavily on TLS certificates between components, and TLS validation cares about time. If your nodes' clocks drift apart by more than a few minutes — which happens more than you'd expect on VMs, especially after a host is suspended or migrated — you'll start seeing certificate and etcd errors that have nothing obviously to do with the clock. Ubuntu ships with `systemd-timesyncd` enabled by default, so this is usually just a matter of confirming it's on:

```bash
timedatectl set-ntp true
timedatectl status
```

Look for `System clock synchronized: yes` in the output on every node.

## Step 2 — Install containerd (all nodes)

Instead of pulling containerd from Ubuntu's repo, we'll grab the official binaries straight from GitHub with `curl`. It's a few more commands, but it means this step is identical on Ubuntu, Debian, Fedora, RHEL, or anything else with systemd — no package-manager-specific branching, and you're always installing the exact version you asked for instead of whatever your distro happens to be carrying.

These are real, multi-megabyte binaries — not a quick metadata fetch — so each `curl` here actually downloads the full file rather than just resolving instantly. How long that takes depends entirely on your own internet connection; on a fast link it's a few seconds per command, on a slow one it can take a noticeably longer moment. If a command seems to be "hanging," it's probably still downloading — let it finish rather than assuming it's stuck.

```bash
# 1. Download and install the containerd binaries
CONTAINERD_VERSION=2.0.2
curl -fLO https://github.com/containerd/containerd/releases/download/v${CONTAINERD_VERSION}/containerd-${CONTAINERD_VERSION}-linux-amd64.tar.gz
sudo tar Cxzvf /usr/local containerd-${CONTAINERD_VERSION}-linux-amd64.tar.gz

# 2. Install containerd's systemd unit, so it starts on boot and restarts on failure
sudo curl -fLo /etc/systemd/system/containerd.service \
  https://raw.githubusercontent.com/containerd/containerd/main/containerd.service

# 3. Install runc — the low-level component containerd uses to actually create containers
RUNC_VERSION=1.2.4
curl -fLO https://github.com/opencontainers/runc/releases/download/v${RUNC_VERSION}/runc.amd64
sudo install -m 755 runc.amd64 /usr/local/sbin/runc

# 4. Install the CNI plugin binaries containerd's CRI expects at /opt/cni/bin
CNI_VERSION=1.6.2
sudo mkdir -p /opt/cni/bin
curl -fLO https://github.com/containernetworking/plugins/releases/download/v${CNI_VERSION}/cni-plugins-linux-amd64-v${CNI_VERSION}.tgz
sudo tar Cxzvf /opt/cni/bin cni-plugins-linux-amd64-v${CNI_VERSION}.tgz

# 5. Generate containerd's default config, then switch it to the systemd cgroup driver
sudo mkdir -p /etc/containerd
containerd config default | sudo tee /etc/containerd/config.toml
sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml

# 6. Start it
sudo systemctl daemon-reload
sudo systemctl enable --now containerd
```

Double check it actually came up before moving on:

```bash
sudo systemctl status containerd
```

That `SystemdCgroup = true` line in step 5 is, in my experience, the single most common reason a kubelet starts up but never reports `Ready`. Every mainstream distro's init system is systemd at this point, so the cgroup driver kubelet and containerd each use has to match — if it doesn't, the two quietly disagree about who's actually managing resource limits, and things break in ways that don't point back to this one line.

> One trade-off worth knowing: because nothing here is managed by `apt` or `dnf`, nothing will auto-update these binaries either. That's arguably a feature for a cluster — you upgrade containerd/runc/CNI deliberately, on your own schedule, by bumping the version variables above and re-running the steps — but it does mean there's no `apt upgrade` quietly keeping you current. Note the versions you installed somewhere.

## Step 3 — Install kubeadm, kubelet, and kubectl (all nodes)

Same approach here as Step 2: the Kubernetes project publishes `kubeadm`, `kubelet`, and `kubectl` as plain binaries at `dl.k8s.io`, so we'll pull those directly with `curl` instead of adding a distro-specific package repo. One consistent set of commands, on any Linux node.

Same note as before applies: these are real binaries being downloaded, not a package-manager lookup, so give the `curl` command a moment to finish — how long depends on your connection speed, and it's the same on all three nodes.

```bash
# 1. Download the three binaries for the version you want
K8S_VERSION=v1.36.0
curl -fL --remote-name-all \
  "https://dl.k8s.io/release/${K8S_VERSION}/bin/linux/amd64/kubeadm" \
  "https://dl.k8s.io/release/${K8S_VERSION}/bin/linux/amd64/kubelet" \
  "https://dl.k8s.io/release/${K8S_VERSION}/bin/linux/amd64/kubectl"

chmod +x kubeadm kubelet kubectl
sudo mv kubeadm kubelet kubectl /usr/local/bin/

# 2. Install the kubelet systemd unit, so it runs as a proper background service
curl -fsSL "https://raw.githubusercontent.com/kubernetes/release/master/cmd/krel/templates/latest/kubelet/kubelet.service" \
  | sed "s:/usr/bin:/usr/local/bin:g" \
  | sudo tee /etc/systemd/system/kubelet.service

# 3. Install the drop-in that lets kubeadm pass kubelet its runtime config later
sudo mkdir -p /etc/systemd/system/kubelet.service.d
curl -fsSL "https://raw.githubusercontent.com/kubernetes/release/master/cmd/krel/templates/latest/kubeadm/10-kubeadm.conf" \
  | sed "s:/usr/bin:/usr/local/bin:g" \
  | sudo tee /etc/systemd/system/kubelet.service.d/10-kubeadm.conf

# 4. Enable it — it'll sit and wait for kubeadm to configure it in the next step
sudo systemctl daemon-reload
sudo systemctl enable kubelet
```

Don't worry if `kubelet` immediately starts crash-looping in `systemctl status kubelet` right now — that's expected. It has nothing to do yet; it's waiting for the config kubeadm writes in Step 4, a few paragraphs from here.

> **When you do eventually upgrade:** Kubernetes doesn't support skipping minor versions — go one at a time (e.g. 1.36 → 1.37 → 1.38, never straight to 1.38), re-downloading the binaries at each step and following [the official `kubeadm upgrade` process](https://kubernetes.io/docs/tasks/administer-cluster/kubeadm/kubeadm-upgrade/).

> Optional, but genuinely nice to have once you're doing this regularly: `kubectl` autocompletion.
> 
> ```bash
> echo 'source <(kubectl completion bash)' >> ~/.bashrc
> echo 'alias k=kubectl' >> ~/.bashrc
> echo 'complete -o default -F __start_kubectl k' >> ~/.bashrc
> source ~/.bashrc
> ```

## Step 4 — Initialize the control plane (master only)

> ⚠️ **Read this whole step before you run anything.** The command below has two values you need to fill in with your own, and which pod CIDR is right for you depends on your actual network, explained just below the command. Copy-pasting it as-is will likely misconfigure your cluster.

Run this **only on** `master`:

```bash
# both flags below use master's own IP — the one you found for the
# master node back in Step 1, not a worker's IP and not 127.0.0.1
sudo kubeadm init \
  --pod-network-cidr=10.244.0.0/16 \
  --apiserver-advertise-address=192.168.1.10 \
  --control-plane-endpoint=192.168.1.10
```

`192.168.1.10` above is a stand-in for **master's real IP** — the same one from the `hostname -I` check in Step 1 — and it goes on *both* flags.

We're deliberately using the IP for `--control-plane-endpoint` rather than a hostname like `master`. That flag becomes the address baked into your kubeconfig, the API server's TLS certificate, and every worker's config after joining — so whatever you put there has to be resolvable from everywhere, forever. A hostname only resolves if `/etc/hosts` or DNS is set up correctly on every single node; an IP always just works, with nothing else required. (If you skipped naming your nodes `master`/`worker1`/`worker2` in Step 1 — plenty of setups keep their own hostnames like `k8s-cp` — this sidesteps that entirely.)

> **Why** `--pod-network-cidr=10.244.0.0/16` **and not Calico's own default of** `192.168.0.0/16`**?** Calico's manifest ships pre-configured for `192.168.0.0/16`, which is convenient — except our nodes live on `192.168.1.x`, and that's a subnet *inside* `192.168.0.0/16`. Using Calico's literal default here would make the pod network and the actual LAN overlap, which is fragile even when it happens to work. `10.244.0.0/16` avoids that entirely, since it's nowhere near a `192.168.x.x` node network. The trade-off: because it no longer matches Calico's built-in default, we do need one small edit to Calico's manifest in Step 5 to line the two back up — that's covered right there.
> 
> If your own nodes are *not* on a `192.168.x.x` network (say, they're on `10.0.x.x` or `172.16.x.x` already), you can safely use Calico's actual default, `192.168.0.0/16`, instead — just skip the manifest edit in Step 5 and apply it unmodified.

This step takes a minute or two while it pulls images and boots the control-plane components. When it finishes, kubeadm prints a `kubeadm join ...` command containing a token and a certificate hash — **save that output somewhere**, you'll need it for the workers in Step 6. If you forget to, don't worry, it's trivial to regenerate.

### Configure kubectl access

Still on `master`:

```bash
mkdir -p $HOME/.kube
sudo cp -i /etc/kubernetes/super-admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config
```

Note that's `super-admin.conf`, not the more commonly referenced `admin.conf`. On recent kubeadm versions, `admin.conf` is deliberately no longer bound to the `system:masters` group (the one that bypasses RBAC entirely) — it's scoped down, on the theory that a leaked `admin.conf` shouldn't hand over the whole cluster. Full unrestricted access now lives in the separate `super-admin.conf` file instead. If you copy `admin.conf` here, `kubectl` will authenticate fine but then fail almost everything with `Error from server (Forbidden): ... is forbidden: User "kubernetes-admin" cannot list resource ...` — confusing, since it looks like an auth problem when it's actually a permissions one. For a single-operator homelab cluster, `super-admin.conf` is the right file to use.

If you run `kubectl get nodes` right now, you'll see your control-plane node sitting in a `NotReady` state — and that's completely expected. There's no pod network installed yet, so the node genuinely can't be marked ready. That's what we fix next.

## Step 5 — Install Calico (pod networking)

Still on `master`:

Since we used `10.244.0.0/16` in Step 4 instead of Calico's own `192.168.0.0/16` default, don't apply the manifest as-is. The pod pool CIDR in this manifest is set by a `CALICO_IPV4POOL_CIDR` environment variable on the `calico-node` container — but it ships **commented out**, which means Calico ignores it entirely and falls back to `192.168.0.0/16` regardless of what's written there. Just editing the value isn't enough; the block has to actually be uncommented, or the change silently does nothing and pod networking breaks against a mismatched CIDR.

```bash
curl -fLO https://raw.githubusercontent.com/projectcalico/calico/v3.29.1/manifests/calico.yaml

# Uncomment the CALICO_IPV4POOL_CIDR block, then set it to match Step 4's --pod-network-cidr
sed -i '/# - name: CALICO_IPV4POOL_CIDR/,/#   value:/ s/^\(\s*\)# /\1/' calico.yaml
sed -i 's/value: "192.168.0.0\/16"/value: "10.244.0.0\/16"/' calico.yaml

# Confirm it actually took before applying
grep -A1 CALICO_IPV4POOL_CIDR calico.yaml

kubectl create -f calico.yaml
```

The `grep` should print the block uncommented, with your CIDR:

```plaintext
- name: CALICO_IPV4POOL_CIDR
  value: "10.244.0.0/16"
```

> **Stuck with Calico's actual default instead** (your nodes aren't on a `192.168.x.x` network, so you used `--pod-network-cidr=192.168.0.0/16` in Step 4)? Skip the `sed` line and just apply the manifest straight from the URL, unmodified:
> 
> ```bash
> kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.29.1/manifests/calico.yaml
> ```

Give it a minute or two to pull images and start up, watching progress with:

```bash
kubectl get pods -n kube-system -w
```

Along the way you'll likely see `coredns-xxxxx` and `calico-kube-controllers-xxxxx` sitting in `Pending`, with `kubectl describe pod` reporting something like `0/1 nodes are available: 1 node(s) had untolerated taint(s)`. That's expected, not a bug — Kubernetes automatically taints any node `node.kubernetes.io/network-unavailable` until it has a working pod network, specifically so nothing gets scheduled onto a node that can't actually route pod traffic yet. CoreDNS and Calico's own controller both need real networking to function, so neither tolerates that taint. It clears itself the moment Calico finishes setting up networking on the node — no action needed, just give it a minute.

Once the `calico-node` and `calico-kube-controllers` pods show `Running`, check the master node again:

```bash
kubectl get nodes
```

Your control-plane node should now read `Ready` (under whatever hostname it actually has — `master` if you renamed it in Step 1, or its original name if you didn't).

> For a production cluster, Calico's own docs now lean toward the Tigera operator install (`tigera-operator.yaml` + `custom-resources.yaml`) rather than the single manifest we used above — it's more configurable, at the cost of a few more moving parts. The manifest approach here is simpler to reason about and is a perfectly good choice for learning and homelab clusters.

## Step 6 — Join the worker nodes

If you saved the join command from Step 4, run it **on both** `worker1` **and** `worker2`, exactly as kubeadm printed it — don't retype it, and don't swap in a hostname:

```bash
sudo kubeadm join 192.168.1.10:6443 \
  --token <your-token> \
  --discovery-token-ca-cert-hash sha256:<your-hash>
```

That address is master's IP, matching whatever you set `--control-plane-endpoint` to in Step 4 — kubeadm fills this in for you automatically in the real output, so just copy-paste the whole block it gave you.

Didn't save it, or has it been more than 24 hours since `kubeadm init` (the default token lifetime)? Generate a fresh one from the master — this is always safe to run again:

```bash
kubeadm token create --print-join-command
```

Copy the full output and run it on each worker node.

## Step 7 — Verify the cluster

Back on `master`:

```bash
kubectl get nodes -o wide
```

You're looking for all three nodes in a `Ready` state. The `NAME` column shows each node's actual hostname, so yours may read differently than this example — that's fine, only the `STATUS` column matters here:

```plaintext
NAME      STATUS   ROLES           AGE   VERSION
master    Ready    control-plane   10m   v1.36.0
worker1   Ready    <none>          3m    v1.36.0
worker2   Ready    <none>          3m    v1.36.0
```

Once that looks right, it's worth proving to yourself the cluster can actually schedule and network real workloads, not just report a healthy status:

```bash
kubectl create deployment nginx-test --image=nginx --replicas=3
kubectl get pods -o wide
```

You should see three `nginx-test` pods land across your worker nodes, each with its own pod IP. If they do, congratulations — you've built a working Kubernetes cluster by hand.

![](https://cdn.hashnode.com/uploads/covers/641aa341e592dd06478dfa43/da7b97db-2519-4de9-88d7-893163ed82a4.png align="center")

## Troubleshooting

| Symptom | Likely cause | Fix |
| --- | --- | --- |
| Node stuck `NotReady` after `kubeadm init` | No CNI installed yet | Install Calico (Step 5) — this is expected before that |
| `kubelet` fails to start, or swap returns after a reboot | Swap still enabled, possibly via zram | Confirm `swapoff -a` ran; check for a zram swap unit (see the callout in Step 1) |
| Node `NotReady`, kubelet logs show cgroup errors | containerd/kubelet cgroup driver mismatch | Re-check `SystemdCgroup = true` in `/etc/containerd/config.toml`, then restart containerd |
| A worker never fully joins, or two nodes' statuses seem to overwrite each other | Duplicate `machine-id` / `product_uuid` from cloning a VM template | Follow Step 1b to regenerate the machine-id on the affected node |
| `kubeadm join` hangs or times out | Firewall blocking required ports | See the port table below |
| `kubeadm join` fails with "token expired" | Default token TTL is 24 hours | Run `kubeadm token create --print-join-command` on the master |
| Pods can't reach each other across nodes | `br_netfilter` not loaded, or pod CIDR mismatch with CNI config | Re-check Step 1's sysctl/module setup; confirm `--pod-network-cidr` matches Calico's CIDR |
| Random TLS / certificate errors, etcd instability | Clock drift between nodes | Confirm `timedatectl status` shows synced on every node |
| `kubectl get nodes` → `Error from server (Forbidden): ... User "kubernetes-admin" cannot list resource ...` | You copied `admin.conf` instead of `super-admin.conf` — `admin.conf` is no longer full cluster-admin by default on recent kubeadm | Redo the `cp` in "Configure kubectl access" using `/etc/kubernetes/super-admin.conf` instead |
| `kubectl`/`dial tcp: lookup <name> ... server misbehaving` | `--control-plane-endpoint` or the join command used a hostname (like `master`) that isn't resolvable on this node | Use the master's IP everywhere instead (Steps 4 and 6 already do this) — or add the missing name to `/etc/hosts` |
| `coredns`/`calico-kube-controllers` stuck `Pending`, `kubectl describe pod` shows `untolerated taint(s)` | Expected — the node is tainted `network-unavailable` until Calico finishes setting up pod networking | Not a bug, no action needed — resolves itself once Calico's pods go `Running` |
| No `kube-proxy-xxxxx` or `coredns-xxxxx` pods in `kubectl get pods -n kube-system` at all | The `kubeadm init` addon phase failed silently, most likely because `--control-plane-endpoint` used an unresolvable hostname at the time | Add the missing name to `/etc/hosts`, then run `sudo kubeadm init phase addon all --pod-network-cidr=<your CIDR>` — no full reset needed |

### Ports to open if you have host firewalls enabled

If UFW (or another host firewall) is active on your VMs, this is the minimum set of ports each role needs open. If you're on an isolated lab network behind your own router, you may reasonably decide not to run a host firewall at all — but if you do, here's the map:

| Port | Node | Purpose |
| --- | --- | --- |
| 6443 | master | Kubernetes API server |
| 2379–2380 | master | etcd |
| 10250 | all | kubelet API |
| 10259 | master | kube-scheduler |
| 10257 | master | kube-controller-manager |
| 30000–32767 | workers | NodePort services |
| 179 | all | Calico BGP |
| 4789/UDP | all | Calico VXLAN (if using VXLAN mode) |

With UFW specifically, opening the master's ports looks like this:

```bash
sudo ufw allow 6443/tcp
sudo ufw allow 2379:2380/tcp
sudo ufw allow 10250/tcp
sudo ufw allow 10259/tcp
sudo ufw allow 10257/tcp
sudo ufw allow 179/tcp
sudo ufw allow 4789/udp
```

And on each worker:

```bash
sudo ufw allow 10250/tcp
sudo ufw allow 30000:32767/tcp
sudo ufw allow 179/tcp
sudo ufw allow 4789/udp
```

> **On Fedora/RHEL/Rocky/Alma instead of Ubuntu?** Those use `firewalld`, not `ufw`. The master's rules translate to:
> 
> ```bash
> sudo firewall-cmd --permanent --add-port=6443/tcp --add-port=2379-2380/tcp \
>   --add-port=10250/tcp --add-port=10259/tcp --add-port=10257/tcp \
>   --add-port=179/tcp --add-port=4789/udp
> sudo firewall-cmd --reload
> ```
> 
> and each worker's to:
> 
> ```bash
> sudo firewall-cmd --permanent --add-port=10250/tcp --add-port=30000-32767/tcp \
>   --add-port=179/tcp --add-port=4789/udp
> sudo firewall-cmd --reload
> ```

### Running on a cloud VM? There's a second firewall to open

If your nodes are on AWS, GCP, Azure, or any cloud provider, UFW isn't the only thing standing in the way — the cloud's own network firewall sits in front of the instance and blocks traffic before it ever reaches the OS. You need to open the same ports there too:

*   **AWS EC2** — a Security Group attached to the instances
    
*   **GCP** — a VPC firewall rule
    
*   **Azure** — a Network Security Group (NSG)
    

Rather than hand-listing every port from the table above in your cloud firewall, the simpler and more durable approach is to allow **all traffic between the three nodes' private IPs**, on all ports — source the rule from the security group/tag itself, or from your VPC's private CIDR range, not from `0.0.0.0/0`. That way you don't have to keep coming back to open one more port every time you add an ingress controller or a new NodePort service later. Keep public inbound access restricted to just what actually needs to be internet-facing (SSH on 22, and later, whatever port your ingress controller listens on).

## What's next

The cluster is up, but right now it's bare — no ingress, no persistent storage, nothing watching it. Reasonable next steps, roughly in the order I'd tackle them:

*   **Ingress controller** (e.g. ingress-nginx) so you can route HTTP traffic into the cluster by hostname
    
*   **Persistent storage** — a `StorageClass` backed by something like Longhorn or NFS, since a kubeadm cluster doesn't ship with one out of the box
    
*   **Metrics server**, so `kubectl top` and horizontal pod autoscaling actually have data to work with
    
*   **A GitOps workflow** (Argo CD or Flux), so you stop applying manifests by hand the moment this cluster holds anything you care about
    

## Closing thoughts

None of these steps are individually hard — that's really the whole point of walking through them like this. Kubernetes' reputation for complexity mostly comes down to not knowing which one of a dozen small, mechanical steps is the one that's missing when something doesn't work. Once you've done it by hand, end to end, "how does a pod actually get scheduled and networked" stops being magic and starts being a system you can reason about.

If you hit an error this post doesn't cover, `kubectl describe` on the resource in question and `journalctl -u kubelet -f` on the affected node will get you most of the way to an answer.
