# kubeadm Cheat Sheet

> **Tool:** kubeadm
> **Category:** Containers & Orchestration
> **Verified against:** kubeadm v1.37.0, flags verified via `kubeadm --help` / `kubeadm init --help` /
> `kubeadm join --help` / `kubeadm upgrade --help` / `kubeadm token --help` / `kubeadm certs --help` /
> `kubeadm reset --help` / `kubeadm config --help` run locally, 2026-08-29. `kubeadm init`/`join` were not
> executed live in this sandbox (no real container runtime + kernel networking prerequisites available) —
> those workflows are documented from `--help` output plus current https://kubernetes.io official docs,
> and marked illustrative below where noted.
> **Official docs:** https://kubernetes.io/docs/reference/setup-tools/kubeadm/

## 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

```mermaid
flowchart TD
    A(["kubeadm init"]) --> B["preflight: check swap, ports,\nkernel modules, CRI socket"]
    B --> C["certs: generate CA + all\ncomponent certificates"]
    C --> D["kubeconfig: admin.conf,\nkubelet.conf, controller-manager.conf, scheduler.conf"]
    D --> E["etcd: write local etcd\nstatic Pod manifest"]
    E --> F["control-plane: write kube-apiserver,\nkube-controller-manager, kube-scheduler manifests"]
    F --> G["kubelet-start: configure and\n(re)start the kubelet"]
    G --> H{"wait-control-plane:\nAPI server healthy?"}
    H -->|"No, timeout"| X["init fails,\npreflight/logs show why"]
    H -->|"Yes"| I["upload-config + upload-certs:\npersist config to a ConfigMap"]
    I --> J["mark-control-plane +\nbootstrap-token"]
    J --> K["addon: CoreDNS + kube-proxy"]
    K --> L(["show-join-command"])

    classDef ok fill:#e5f5ea,stroke:#1f8a4c,color:#10161c
    classDef crit fill:#fbe8e6,stroke:#b3261e,color:#10161c
    classDef info fill:#e5f0fa,stroke:#1d6fb8,color:#10161c
    class A,L ok
    class X crit
    class B,C,D,E,F,G,H,I,J,K info
```

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

```bash
# 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 version
```

> [!IMPORTANT]
> 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`

```bash
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`

```bash
# 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 joining
```

```mermaid
sequenceDiagram
    participant New as New Node
    participant Token as Bootstrap Token (in cluster)
    participant CP as Control Plane

    New->>CP: Connect using --token, present as bearer credential
    CP-->>New: Validate token, return cluster CA (pinned via --discovery-token-ca-cert-hash)
    New->>New: Trust established both directions - node trusts CP's identity, CP trusts node's request
    New->>CP: Submit kubelet TLS bootstrap CSR
    CP-->>New: Auto-approve CSR, issue kubelet client certificate
    New->>New: kubelet starts, registers as a Node object
```

**This 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

```bash
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 immediately
```

The 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

```bash
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 join
```

> [!CAUTION]
> **`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

```bash
# 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

```bash
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 removal
```

> [!WARNING]
> `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`

```bash
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 version
```

## Config 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:

```yaml
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.log
```

```bash
kubeadm init --config kubeadm-config.yaml --upload-certs
```

## Real-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:

```bash
# 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:

```bash
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.0
```

## Real-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.

```bash
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:

```yaml
# .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.yaml
```

## Common pitfalls

- **Running `kubeadm init` a second time on a node that already has a cluster** — it refuses outright
  (preflight fails on existing state) rather than reinitializing; `kubeadm reset` first if the intent is a
  genuine rebuild.
- **Forgetting `--upload-certs` on the first `init`**, then being unable to add a second control-plane node
  without manually distributing `/etc/kubernetes/pki/*` — see the HA scenario above.
- **Treating `kubeadm certs renew` as 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 plan` blocks
  this, but only if you actually run `plan` before `apply` instead 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.
