Skip to main content

Bootstrap Kubernetes Cluster with kubeadm

Production Purpose: kubeadm is the official Kubernetes cluster bootstrapping tool. It handles control-plane initialization, TLS certificate generation, etcd setup, and node join tokens — the same steps cloud providers automate for managed Kubernetes (EKS, GKE, AKS).


What kubeadm Does


Node IP Reference

NodeHostnameIP
Control Planek8s-control192.168.90.26
Worker 1k8s-worker1192.168.90.27
Worker 2k8s-worker2192.168.90.28

Install kubeadm, kubelet, kubectl (All Nodes)

Run this on all 3 VMs.

Add Kubernetes Repository

Input:

apt update && apt install -y apt-transport-https ca-certificates curl gpg
mkdir -p /etc/apt/keyrings
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.30/deb/Release.key \
| gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.30/deb/ /' \
| tee /etc/apt/sources.list.d/kubernetes.list
Common mistake

The deb line must be a single line — no line breaks inside the string. Using tee instead of > also makes the entry visible in output so you can verify it immediately.

Verify the file looks correct:

cat /etc/apt/sources.list.d/kubernetes.list

Output:

deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.30/deb/ /

It must be exactly one line with no backslashes.

No other output is expected.

Install Kubernetes Components

Input:

apt update
apt install -y kubelet kubeadm kubectl
apt-mark hold kubelet kubeadm kubectl

apt-mark hold prevents automatic upgrades from breaking your cluster.

Verify Installation

Input:

kubeadm version
kubelet --version
kubectl version --client

Output:

kubeadm version: &version.Info{Major:"1", Minor:"30", GitVersion:"v1.30.14", GitCommit:"9e18483918821121abdf9aa82bc14d66df5d68cd", GitTreeState:"clean", BuildDate:"2025-06-17T18:34:53Z", GoVersion:"go1.23.10", Compiler:"gc", Platform:"linux/amd64"}
Kubernetes v1.30.14
Client Version: v1.30.14
Kustomize Version: v5.0.4-0.20230601165947-6ce0bf390ce3

Initialize the Control Plane (VM1 only)

Run on k8s-control (192.168.90.26) only.

Create kubeadm Config File

Create: kubeadm-config.yaml

apiVersion: kubeadm.k8s.io/v1beta3
kind: InitConfiguration
localAPIEndpoint:
advertiseAddress: 192.168.90.26
bindPort: 6443
---
apiVersion: kubeadm.k8s.io/v1beta3
kind: ClusterConfiguration
kubernetesVersion: v1.30.0
clusterName: k8s-prod
networking:
podSubnet: 10.244.0.0/16 # Calico will use this range
serviceSubnet: 10.96.0.0/12
controlPlaneEndpoint: 192.168.90.26:6443
---
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
cgroupDriver: systemd
Why podSubnet: 10.244.0.0/16?

This is the CIDR Calico will use for pod IPs. Do NOT change it after cluster creation — it breaks all pod networking.

Run kubeadm init

Input:

kubeadm init --config kubeadm-config.yaml --upload-certs 2>&1 | tee kubeadm-init.log

Output (last few lines):

Your Kubernetes control-plane has initialized successfully!

To start using your cluster, you need to run the following as a regular user:

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

Alternatively, if you are the root user, you can run:

export KUBECONFIG=/etc/kubernetes/admin.conf

You should now deploy a pod network to the cluster.
Run "kubectl apply -f [podnetwork].yaml" with one of the options listed at:
https://kubernetes.io/docs/concepts/cluster-administration/addons/

You can now join any number of the control-plane node running the following command on each as root:

kubeadm join 192.168.90.26:6443 --token ligchj.j6fb8wfls2qr0r6z \
--discovery-token-ca-cert-hash sha256:ce64982663604b98fbb3a7ddccca412acd9a1e63bf6b01a19758628114f937ed \
--control-plane --certificate-key e26edb6d2b9a509112247470b6c32ba16717a3fa24a9a82a6eb4e377ccd15765

Please note that the certificate-key gives access to cluster sensitive data, keep it secret!
As a safeguard, uploaded-certs will be deleted in two hours; If necessary, you can use
"kubeadm init phase upload-certs --upload-certs" to reload certs afterward.

Then you can join any number of worker nodes by running the following on each as root:

kubeadm join 192.168.90.26:6443 --token ligchj.j6fb8wfls2qr0r6z \
--discovery-token-ca-cert-hash sha256:ce64982663604b98fbb3a7ddccca412acd9a1e63bf6b01a19758628114f937ed
Save the join command

Copy the entire kubeadm join command. You need it to add workers. It expires in 24 hours.

Configure kubectl for root User

Input:

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

Verify Control Plane

Input:

kubectl get nodes

Output:

NAME          STATUS     ROLES           AGE   VERSION
k8s-control NotReady control-plane 1m v1.30.x

NotReady is expected — no CNI (network plugin) is installed yet.


Join Worker Nodes (VM2 and VM3)

Run the join command from kubeadm-init.log on each worker node.

kubeadm join 192.168.90.26:6443 --token <TOKEN> \
--discovery-token-ca-cert-hash sha256:<HASH>

Output:

This node has joined the cluster:
* Certificate signing request was sent to apiserver
* The kubelet was informed of the new secure connection details

Run 'kubectl get nodes' on the control-plane to see this node join the cluster.

If the Join Token Expired

Tokens expire after 24 hours. Generate a new one on the control plane:

kubeadm token create --print-join-command

Verify All Nodes Joined

Input (on control-plane):

kubectl get nodes -o wide

Output:

NAME           STATUS     ROLES           AGE   VERSION   INTERNAL-IP      OS-IMAGE
k8s-control NotReady control-plane 5m v1.30.x 192.168.90.26 Ubuntu 22.04
k8s-worker1 NotReady <none> 2m v1.30.x 192.168.90.27 Ubuntu 22.04
k8s-worker2 NotReady <none> 1m v1.30.x 192.168.90.28 Ubuntu 22.04

All nodes show NotReady until Calico is installed in Phase 03.


Verify System Pods

Input:

kubectl get pods -n kube-system

Output:

NAME                                  READY   STATUS    RESTARTS   AGE
coredns-xxx 0/1 Pending 0 5m
etcd-k8s-control 1/1 Running 0 5m
kube-apiserver-k8s-control 1/1 Running 0 5m
kube-controller-manager-k8s-control 1/1 Running 0 5m
kube-scheduler-k8s-control 1/1 Running 0 5m
kube-proxy-xxx 1/1 Running 0 5m

coredns is Pending — it waits for pod networking (Calico).


Copy kubeconfig to Your Laptop (Optional)

If you want to run kubectl from your own machine:

# On your laptop
mkdir -p ~/.kube
scp root@192.168.90.26:/etc/kubernetes/admin.conf ~/.kube/config

Verify from your laptop:

kubectl cluster-info

Output:

Kubernetes control plane is running at https://192.168.90.26:6443

Understanding the Control Plane Components

ComponentRole
etcdKey-value store for all cluster state — losing it = losing the cluster
kube-apiserverAll kubectl commands go here — the single source of truth
kube-schedulerDecides which node a Pod runs on
kube-controller-managerReconciles desired state (Deployments, ReplicaSets, etc.)
kubeletNode agent — runs on every node, talks to containerd
kube-proxyMaintains iptables rules for Service networking

Troubleshooting

SymptomCauseFix
kubeadm init fails on preflightSwap enabledswapoff -a
kubeadm init fails on preflightcontainerd not runningsystemctl start containerd
Workers can't joinFirewall blocking 6443ufw allow 6443/tcp on control-plane
Node stays NotReadyNo CNI installedInstall Calico (Phase 03)
coredns stays PendingNo CNI installedInstall Calico (Phase 03)
kubeadm token expired24h TTLkubeadm token create --print-join-command
kubectl says no serverkubeconfig missingcp /etc/kubernetes/admin.conf ~/.kube/config
First kubectl works, then connection refusedAPI server crashed after initSee runbook below ↓

⚠️ API Server Works Once Then Drops (connection refused)

This is the most common post-init failure. The API server started once (you got one successful kubectl get nodes), then crashed. The kubeconfig is correct — the server itself is down.

Step 1 — Check if kube-apiserver container is still running:

crictl ps -a | grep apiserver

If the STATUS column shows Exited → the container crashed. Continue below.

Step 2 — Read kube-apiserver crash logs:

crictl logs $(crictl ps -a | grep apiserver | awk '{print $1}')

Or via the pod log file:

ls /var/log/pods/kube-system_kube-apiserver*/kube-apiserver/
cat /var/log/pods/kube-system_kube-apiserver*/kube-apiserver/0.log | tail -40

Step 3 — Check kubelet for the root cause:

journalctl -u kubelet -n 80 --no-pager

Step 4 — Interpret the error:

Error in logsRoot CauseFix
failed to initialize cgroup driverSystemdCgroup = false in containerdSet to true, restart containerd
bind: address already in use :6443Another process on port 6443ss -tlnp | grep 6443 then kill it
etcd cluster is unavailableetcd also crashedcrictl ps -a | grep etcd
x509: certificate errorsCert mismatch or wrong advertiseAddressReset and reinit with correct IP
permission denied on pki filesWrong file permissionschmod 600 /etc/kubernetes/pki/*.key

Step 5 — Fix cgroup mismatch (most common cause):

grep "SystemdCgroup" /etc/containerd/config.toml

If it shows false:

sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
systemctl restart containerd
systemctl status containerd

Step 6 — Full reset and reinitialize if needed:

caution

This wipes the cluster. Run only on the control-plane. Workers need to re-join after.

kubeadm reset -f
rm -rf /etc/kubernetes /var/lib/etcd ~/.kube
iptables -F && iptables -t nat -F && iptables -t mangle -F && iptables -X
systemctl restart containerd kubelet

Then reinitialize:

kubeadm init --config kubeadm-config.yaml --upload-certs 2>&1 | tee kubeadm-init.log
mkdir -p $HOME/.kube
cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
chown $(id -u):$(id -g) $HOME/.kube/config

Firewall Rules Required

# On control-plane
ufw allow 6443/tcp # API Server
ufw allow 2379:2380/tcp # etcd
ufw allow 10250/tcp # kubelet
ufw allow 10251/tcp # kube-scheduler
ufw allow 10252/tcp # kube-controller-manager

# On worker nodes
ufw allow 10250/tcp # kubelet
ufw allow 30000:32767/tcp # NodePort services

Production Best Practices

PracticeReason
Use kubeadm-config.yamlVersion-controlled, reproducible cluster init
Pin Kubernetes versionPrevent uncontrolled upgrades
Backup etcd immediately after initetcd = cluster state
Use --upload-certs flagEnables HA control-plane certificate sharing
Save kubeadm-init.logContains join command and CA hash
Enable audit loggingRequired for production compliance