kubeadm
.mdVerified against kubeadm v1.37.0, flags verified via `kubeadm --help` / `kubeadm init --help` / · official docs
What it is and where it fits 🎯#
kubeadm is the standard tool for bootstrapping a conformant Kubernetes cluster — it does the one job of
"turn a set of machines with a container runtime installed into a working control plane and worker nodes,"
and deliberately nothing else. It doesn't provision infrastructure (that's Terraform/Cluster API's job), it
doesn't manage day-2 workloads (that's kubectl/Helm/kustomize), and it doesn't debug a running container
(that's crictl, one layer below it). Once a cluster exists, kubeadm's job mostly ends — you reach for it
again only to join a new node, upgrade the control plane, renew certificates, or reset a node you're
decommissioning. It's the tool CKA/CKS exam environments, kubespray, and most from-scratch homelab and
on-prem clusters are built on, in contrast to managed offerings (EKS/GKE/AKS) where a cloud provider runs
the equivalent of kubeadm init for you behind an API call.
How kubeadm init actually works#
Every one of those stages is also an individually invokable kubeadm init phase <name> — useful for
re-running just one step (regenerating a single cert, re-writing one static Pod manifest) instead of
re-running the whole sequence, and for understanding exactly which stage a failed init died at from the
phase name in the error output.
Installation#
# 1. Add Kubernetes' community-owned apt repo (pkgs.k8s.io) — the old apt.kubernetes.io repo
# was frozen in 2023 and fully removed in 2024; don't follow guides that still reference it.
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.34/deb/Release.key \
| sudo 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.34/deb/ /' \
| sudo tee /etc/apt/sources.list.d/kubernetes.list
sudo apt-get update
sudo apt-get install -y kubelet kubeadm kubectl
sudo apt-mark hold kubelet kubeadm kubectl # prevent an unattended-upgrade from skewing versions
# 2. Or a standalone binary — no package manager, useful for a scripted/offline install:
curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubeadm"
chmod +x kubeadm && sudo mv kubeadm /usr/local/bin/
kubeadm versionImportant
The repo path is versioned per Kubernetes minor version (.../core:/stable:/v1.34/deb/) — it does not
auto-track new minors. Bumping a cluster from 1.34 to 1.35 means re-pointing this apt source, not just
running apt upgrade, and kubeadm upgrade plan (below) won't even see the new minor until you do.
kubeadm alone doesn't bootstrap anything without a CRI-compatible container runtime already installed and running on the host (containerd or CRI-O) — see the crictl cheat sheet in this same category for inspecting that runtime once the cluster is up.
Bootstrapping a cluster: init#
kubeadm init # sane defaults, single control-plane node
kubeadm init --pod-network-cidr=10.244.0.0/16 # required up front if your CNI needs a specific range (e.g. Flannel)
kubeadm init --control-plane-endpoint "cluster-endpoint:6443" --upload-certs # HA-ready from the first node
kubeadm init --kubernetes-version v1.37.0 # pin the version instead of trusting "stable-1"
kubeadm init --dry-run # print every manifest/cert it *would* generate, changes nothing
kubeadm init --image-repository my-registry.internal/k8s # air-gapped/private-mirror control-plane images--control-plane-endpoint and --upload-certs only matter if you're building (or might later grow into) a
multi-control-plane HA cluster — setting them on the very first init is far less painful than trying to
retrofit a stable endpoint onto a cluster that already has workloads on it.
Illustrative tail of a successful kubeadm init run (not captured live here — see the header note; shape
confirmed against current official docs):
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
Then you can join any number of worker nodes by running the following on each as root:
kubeadm join cluster-endpoint:6443 --token abcdef.0123456789abcdef \
--discovery-token-ca-cert-hash sha256:1234...
Tip
Copy the kubeadm join command from the real init output, not from a cheat sheet. The token and CA
cert hash are generated fresh per cluster and expire (see Managing bootstrap tokens below) — there's no
stable, reusable value to memorize here the way there is for most CLI flags.
Joining nodes: join#
# Worker node — the exact command kubeadm init printed:
kubeadm join cluster-endpoint:6443 --token abcdef.0123456789abcdef \
--discovery-token-ca-cert-hash sha256:1234...
# Additional control-plane node — same shape, plus --control-plane and the shared certificate key:
kubeadm join cluster-endpoint:6443 --token abcdef.0123456789abcdef \
--discovery-token-ca-cert-hash sha256:1234... \
--control-plane --certificate-key <64-hex-char-key-from-init--upload-certs>
kubeadm join --dry-run --token ... --discovery-token-ca-cert-hash ... # preview without joiningThis diagram shows the mutual-trust handshake, not a one-way login — a bare --token without
--discovery-token-ca-cert-hash lets the node trust a server claiming to be the control plane, but
doesn't let the control plane verify which server that was, which is exactly the gap
--discovery-token-ca-cert-hash closes.
Warning
--discovery-token-unsafe-skip-ca-verification exists and works, but it means a node joins whatever host
answers on that IP/port without checking it's actually your control plane — a real attack surface on any
network segment you don't fully control. Reserve it for a genuinely trusted, isolated lab network, never a
production join.
Managing bootstrap tokens#
kubeadm token list # tokens currently valid, with TTL and usages
kubeadm token create # a new token, default 24h TTL
kubeadm token create --ttl 2h --print-join-command # short-lived token, prints the full ready-to-run join command
kubeadm token create --ttl 0 # never-expiring token (only for a controlled, trusted environment)
kubeadm token delete abcdef.0123456789abcdef # invalidate a token immediatelyThe token kubeadm init prints expires after 24 hours by default (--token-ttl) — joining a node a week
after cluster creation means generating a fresh token with kubeadm token create, not reusing the one from
the original init output.
Managing certificates#
kubeadm certs check-expiration # every cert's expiry date, at a glance
kubeadm certs renew all # renew everything, unconditionally, regardless of current expiry
kubeadm certs renew apiserver # renew just one certificate
kubeadm certs certificate-key # print a fresh key for a future `--upload-certs` HA joinCaution
kubeadm certs renew writes new certificate files but does not restart the static Pods that read
them. The control-plane components (kube-apiserver, etcd, etc.) keep serving with the old
certificate in memory until their static Pod actually restarts — either delete the affected Pod manually
(the kubelet recreates it and it picks up the new cert) or expect it to happen on the next node reboot.
Renewing certs and walking away without restarting anything is the single most common reason a team
"renewed the cert" and still hit an expiry outage on the exact date they thought they'd fixed.
kubeadm's default cert lifetime is one year, except the root CA (10 years) — kubeadm upgrade apply
auto-renews all certs about to expire as part of every upgrade, which is why a cluster upgraded at least
annually rarely hits this manually at all; it's the clusters left on one version for 18+ months that get
bitten.
Upgrading a cluster#
# Always upgrade kubeadm itself first (apt/binary), then run:
kubeadm upgrade plan # what's available, and any pre-upgrade warnings
kubeadm upgrade apply v1.35.2 # upgrade the primary control-plane node
kubeadm upgrade node # run on every OTHER control-plane node, then every worker
kubeadm upgrade diff v1.35.2 # preview static-manifest changes without applying (like `apply --dry-run`)The real-world order matters and kubeadm won't enforce it for you: primary control-plane node first
(upgrade apply), then every additional control-plane node (upgrade node), then every worker node
(upgrade node, plus apt-get install kubelet=<version> kubectl=<version> and a kubelet restart on each).
Kubernetes only supports skipping at most one minor version per upgrade step (1.34 → 1.35 is fine; 1.34 →
1.36 directly is not) — kubeadm upgrade plan refuses and tells you so rather than silently doing a
multi-minor jump.
Tip
Drain each node before upgrading its kubelet (kubectl drain <node> --ignore-daemonsets), and
kubectl uncordon it after. kubeadm upgrade node only touches kubeadm/kubelet-managed state on that
host — it doesn't evict running Pods for you, so skipping the drain means workloads keep scheduling onto
(or already running on) a node about to restart its kubelet mid-upgrade.
Resetting a node#
kubeadm reset # interactive confirmation, best-effort undo of init/join
kubeadm reset --force # no prompt — for scripted teardown
kubeadm reset phase cleanup-node # just the filesystem/iptables cleanup phase, skip etcd removalWarning
kubeadm reset is explicitly a best-effort revert — its own --help text says so. It doesn't clean
up CNI configuration left behind by whatever network plugin you installed (Calico/Cilium/Flannel leave
their own files under /etc/cni/net.d/ and their own iptables rules), and it doesn't remove the node's
etcd member if you skip that phase on the wrong node in a multi-control-plane cluster, which can leave
the etcd quorum in a bad state. Read the CNI plugin's own uninstall docs before considering a reset node
truly clean, and always remove a control-plane node's etcd membership before wiping its disk, not after.
Cluster and image configuration: kubeadm config#
kubeadm config print init-defaults # dump the full default InitConfiguration/ClusterConfiguration
kubeadm config print join-defaults # same, for a JoinConfiguration
kubeadm config images list # every control-plane image this version needs, by tag
kubeadm config images pull # pre-pull them all onto this host (air-gapped prep)
kubeadm config validate --config kubeadm-config.yaml # lint a config file before trusting it in a real init
kubeadm config migrate --old-config old.yaml --new-config new.yaml # bump a config file to the current API versionConfig file format#
Rather than a long flag list, most real deployments drive kubeadm init from a config file — it's
versionable, reviewable in a PR, and covers settings (like certSANs or a custom imageRepository) that
don't all have CLI flag equivalents:
apiVersion: kubeadm.k8s.io/v1beta4
kind: InitConfiguration
localAPIEndpoint:
advertiseAddress: 10.0.1.10
bindPort: 6443
nodeRegistration:
criSocket: unix:///var/run/containerd/containerd.sock
---
apiVersion: kubeadm.k8s.io/v1beta4
kind: ClusterConfiguration
kubernetesVersion: v1.37.0
controlPlaneEndpoint: "cluster-endpoint:6443"
networking:
podSubnet: 10.244.0.0/16
serviceSubnet: 10.96.0.0/12
apiServer:
certSANs:
- "cluster-endpoint"
- "10.0.1.10"
extraArgs:
- name: audit-log-path
value: /var/log/kubernetes/audit.logkubeadm init --config kubeadm-config.yaml --upload-certsReal-world scenario: bootstrapping a 3-node HA control plane#
A platform team building an on-prem cluster wants three control-plane nodes behind a load balancer from day one, not a single node retrofitted later:
# On the load balancer, point cluster-endpoint:6443 at all 3 control-plane node IPs.
# On the FIRST control-plane node:
kubeadm init --control-plane-endpoint "cluster-endpoint:6443" --upload-certs --pod-network-cidr=10.244.0.0/16
# On EACH of the other two control-plane nodes, using the join command + --certificate-key from init's output:
kubeadm join cluster-endpoint:6443 --token abcdef.0123456789abcdef \
--discovery-token-ca-cert-hash sha256:1234... \
--control-plane --certificate-key <key-from-init-output>
# On every worker node, the plain (non---control-plane) join command.--upload-certs on the first init is what makes the second and third control-plane nodes' join --control-plane possible without manually copying /etc/kubernetes/pki/* between hosts — without it,
every additional control-plane join fails looking for certs it never received.
Real-world scenario: air-gapped install with pre-pulled images#
A regulated environment has no outbound internet access from cluster nodes. kubeadm config images pull
turns "what images does this version need" from a guessing game into an exact, versioned list:
kubeadm config images list --kubernetes-version v1.37.0 > images.txt
# ... mirror each image in images.txt into the internal registry ...
kubeadm init --image-repository my-registry.internal/k8s --kubernetes-version v1.37.0Real-world scenario: recovering from an unnoticed cert expiry#
A cluster left untouched for 14 months (no kubeadm upgrade run) hits its one-year cert expiry — the API
server starts refusing kubelet/controller-manager connections with TLS handshake errors in its logs.
kubeadm certs check-expiration # confirm which certs actually expired
kubeadm certs renew all
# then restart every control-plane static Pod so it picks up the new files:
sudo mv /etc/kubernetes/manifests/*.yaml /tmp/ && sleep 20 && sudo mv /tmp/*.yaml /etc/kubernetes/manifests/Moving the static Pod manifests out of /etc/kubernetes/manifests/ and back is the standard trick to force
the kubelet to notice a change and recreate them — kubeadm certs renew alone (per the CAUTION above) never
restarts anything on its own.
CI/CD integration: validating config before it ever touches a cluster#
kubeadm itself isn't an ephemeral-test-cluster tool the way kind or k3d are — those exist specifically
for spinning up and tearing down disposable clusters fast in CI. kubeadm's real CI niche is validating a
kubeadm-config.yaml change before it's applied to a real fleet, catching a typo or an unsupported field
before it reaches a maintenance window:
# .github/workflows/kubeadm-config-lint.yml
name: Validate kubeadm config
on:
pull_request:
paths: ["kubeadm-config.yaml"]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install kubeadm
run: |
curl -LO "https://dl.k8s.io/release/v1.37.0/bin/linux/amd64/kubeadm"
chmod +x kubeadm && sudo mv kubeadm /usr/local/bin/
- name: Validate
run: kubeadm config validate --config kubeadm-config.yamlCommon pitfalls#
- Running
kubeadm inita second time on a node that already has a cluster — it refuses outright (preflight fails on existing state) rather than reinitializing;kubeadm resetfirst if the intent is a genuine rebuild. - Forgetting
--upload-certson the firstinit, then being unable to add a second control-plane node without manually distributing/etc/kubernetes/pki/*— see the HA scenario above. - Treating
kubeadm certs renewas a complete fix — it doesn't restart the static Pods; see the CAUTION above. - Skipping node drains during
kubeadm upgrade node— the command upgrades kubeadm/kubelet state, not workload placement; a drain is a separate, deliberate step. - Jumping more than one Kubernetes minor version in a single upgrade —
kubeadm upgrade planblocks this, but only if you actually runplanbeforeapplyinstead of guessing a target version. - Assuming the pinned apt repo tracks new minors automatically — see the IMPORTANT callout under Installation; it's pinned per minor by design.
Exit codes and when to reach for something else#
0 on success; non-zero on any preflight, cert, or API-server-readiness failure — the specific phase name
in the failure output (see the init diagram above) is the fastest way to narrow down which stage broke.
If the goal is a throwaway local/CI test cluster, reach for kind or k3d instead — both wrap
containerized single-binary clusters that come up in seconds and don't require kubeadm's host-level
prerequisites at all. If the goal is provisioning cloud infrastructure and the cluster together with
ongoing lifecycle management (not just the bootstrap step), Cluster API or a managed offering (EKS/GKE/AKS)
replaces kubeadm entirely rather than sitting alongside it. Once a kubeadm-built cluster is running, day-to-day
resource management is kubectl's job, and low-level container/runtime debugging on a node is crictl's — both
covered in their own cheat sheets in this category.