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

Search for a command to run...

No comments yet. Be the first to comment.
A hands-on series building real Kubernetes clusters from scratch — no managed services, no shortcuts. Every post uses kubeadm and raw Linux tooling directly, including the actual errors and gotchas hit along the way, not just the happy path.
Discovering the Kernel, User Space, and Server Multitasking Realities

When you type google.com in your browser, it feels instant. But behind the scenes, there’s a powerful system mapping human-readable domains to machine-friendly IPs: DNS (Domain Name System). For DevOps engineers, DNS is more than just theory—it’s at ...

Avoid Common Pitfalls to Build Better, Faster, and More Secure Docker Images

Introduction In our previous discussion on Docker image optimization, we focused on reducing image size to achieve faster deployments and lower storage costs. Now, let’s address another vital aspect of Docker workflows: build speed. The time it takes...

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.
| 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
curlstraight 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 theufwfirewall commands near the end — swap those forfirewall-cmdif you're on afirewallddistro; 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.
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.
Everything in this step runs on all three VMs — master, worker1, and worker2 — unless noted otherwise.
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
sshsession — noticeably less error-prone.
# on the master
sudo hostnamectl set-hostname master
# on worker1
sudo hostnamectl set-hostname worker1
# on worker2
sudo hostnamectl set-hostname worker2
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:
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.
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:
cat <<EOF | sudo tee -a /etc/hosts
192.168.1.10 master
192.168.1.11 worker1
192.168.1.12 worker2
EOF
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):
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:
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.
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.
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-generatorrather than a plain/etc/fstabentry. 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: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
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.
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.
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:
timedatectl set-ntp true
timedatectl status
Look for System clock synchronized: yes in the output on every node.
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.
# 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:
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
aptordnf, 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 noapt upgradequietly keeping you current. Note the versions you installed somewhere.
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.
# 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 upgradeprocess.
Optional, but genuinely nice to have once you're doing this regularly:
kubectlautocompletion.echo 'source <(kubectl completion bash)' >> ~/.bashrc echo 'alias k=kubectl' >> ~/.bashrc echo 'complete -o default -F __start_kubectl k' >> ~/.bashrc source ~/.bashrc
⚠️ 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:
# 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/16and not Calico's own default of192.168.0.0/16? Calico's manifest ships pre-configured for192.168.0.0/16, which is convenient — except our nodes live on192.168.1.x, and that's a subnet inside192.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/16avoids that entirely, since it's nowhere near a192.168.x.xnode 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.xnetwork (say, they're on10.0.x.xor172.16.x.xalready), 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.
Still on master:
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.
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.
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:
- 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.xnetwork, so you used--pod-network-cidr=192.168.0.0/16in Step 4)? Skip thesedline and just apply the manifest straight from the URL, unmodified: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:
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:
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.
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:
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:
kubeadm token create --print-join-command
Copy the full output and run it on each worker node.
Back on master:
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:
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:
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.
| 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 |
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:
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:
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, notufw. The master's rules translate to: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 --reloadand each worker's to:
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
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).
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
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.