kubeadm Setup With OpenTofu: 13 Steps, 75 Min [2026]

Managed Kubernetes keeps getting the headlines. Recently, AWS announced support for Kubernetes 1.37 in Amazon EKS and EKS Distro. Yet plenty of teams still build their own clusters, because they need control over the control plane, the network, or the bill. For those teams, a kubeadm setup remains the standard route, and it works on any cloud VM you can SSH into.

This tutorial builds a highly available cluster from nothing: three control plane nodes, two workers, and an internal load balancer in front of the API server. OpenTofu 1.13 provisions the machines, cloud-init prepares them, and kubeadm bootstraps Kubernetes. If you want a gentler first run on a single machine, start with our general Kubernetes tutorial and come back here when you want the production-shaped version.

You will finish with a working cluster, a repeatable project you can destroy and rebuild in minutes, and a clear picture of what each kubeadm flag does. Budget about 75 minutes the first time. The second run takes roughly 20, because the code already exists. Every command below was chosen to be copy-pasteable, and every config file ends up in the complete project near the bottom of the page.

Google · Preferred Sources

Don't miss new tech stories on Google

Add Tech Insider once in the Google app and our stories appear in your news suggestions.

Add Now

Why a kubeadm Setup Still Matters in October 2026

The official Kubernetes documentation still describes kubeadm as the tool for creating and managing clusters, and for bringing up a minimum viable, secure control plane. It does one job. It does not provision machines, it does not install a network plugin, and it does not pick a load balancer. That narrow scope is exactly why it pairs well with an infrastructure-as-code tool: OpenTofu owns the machines, kubeadm owns the cluster, and neither tries to do the other’s work.

There are three practical reasons to choose this path over a managed service. First, you control the control plane, so you can tune API server flags, audit policy, and etcd placement. Second, you can run the same recipe on any provider, which matters if you ever move clouds. Third, you learn how the pieces fit. When a managed cluster misbehaves, that knowledge cuts debugging time sharply.

The trade-off is operations. You patch the nodes, you upgrade the control plane, and you back up etcd. Kelsey Hightower, a Kubernetes educator and former Principal Engineer at Google, put the underlying tension well in an older episode of the Go Time podcast: “But in production, you have a lot more concerns than just starting and stopping an application.” This tutorial covers those concerns in the sections on pitfalls, troubleshooting, and upgrades, so the cluster you build does not stay a lab toy.

Two recent shifts make this a good moment to rebuild your recipe. OpenTofu 1.13.0 shipped on September 29, 2026, and the Kubernetes project published guidance on October 6, 2026 about moving to cgroup v2, with the cgroup v1 fallback scheduled for removal in Kubernetes 1.38. Both changes touch the exact files you are about to write. Our wider cloud computing coverage tracks the managed side of the same story if you want to compare.

Prerequisites and Version Matrix

You need a laptop or jump host with a shell, a DigitalOcean account, and basic comfort with SSH. The same code works on other providers with a different OpenTofu provider block, but the commands below use DigitalOcean Droplets because the provider is small, the API is quick, and teardown is clean. If containers are new to you, skim our Docker production stack tutorial first, since kubeadm assumes you know what a container runtime does.

ComponentVersion or requirementWhy it matters
OpenTofu1.13.0 or newer (released September 29, 2026)Security support for the 1.13 series runs through August 2027
Kubernetes packagesv1.37 stream from pkgs.k8s.ioPinned in cloud-init so every node installs the same minor
Node OSNot listedKubernetes recommends cgroup v2
Container runtimecontainerd with SystemdCgroup enabledMust match the kubelet cgroup driver
Network pluginCilium installed with HelmPod networking plus network policy
Local toolsssh, scp, jq, kubectl, helmUsed by the bootstrap script
Cloud accountDigitalOcean API token with read and write scopeOpenTofu authenticates with it

One caveat on the Kubernetes version. Some project pages I checked on October 22, 2026 still describe 1.36 as the current release, while AWS and Oracle announced 1.37 support. Because the sources disagree, this tutorial keeps the minor version in a single variable (k8s_minor). Open the releases page on kubernetes.io before you start and set the variable to the newest stable minor your cloud tooling supports. Nothing else in the project changes when you do.

Check two more things before you begin. Your laptop needs outbound access to the DigitalOcean API and to GitHub release downloads. And you need to know your public IP address, because the firewall in Step 5 only lets that address SSH into the nodes. If you work from a coffee shop or a phone hotspot, that address will change, and Step 5 explains how to update it.

What You Are Building: Architecture of the HA Cluster

The cluster has six machines and one load balancer. Three control plane nodes run the API server, scheduler, controller manager, and a stacked etcd member each. Two workers run your pods. An internal load balancer listens on port 6443 inside a private VPC and spreads API traffic across the three control planes. Nothing exposes the API server to the public internet.

RoleCountRunsReachable from
Control plane3kube-apiserver, etcd, scheduler, controller managerVPC and your admin IP (SSH only)
Worker2kubelet, containerd, your workloadsVPC and your admin IP (SSH only)
Internal load balancer1TCP 6443 forwarding to control planesVPC only
VPC1Private network for node trafficNot applicable
Cloud firewall1Inbound rules for SSH and node portsApplied by tag

Why three control planes and not two? etcd needs a majority to accept writes. With two members, losing one leaves a single node that cannot form a majority, so the cluster stops accepting changes. Three members tolerate one failure, and five tolerate two. Odd numbers are the rule. If you later run stateful systems on top, such as the Apache Kafka KRaft cluster we covered earlier, the same majority logic applies to their own consensus layers, so plan node counts with both in mind.

You reach the API through an SSH tunnel to the first control plane, which forwards local port 6443 to the internal load balancer. It is slower to set up than a public endpoint, and it is far safer. For a walkthrough of the same kubeadm flow on bare metal, the AWS Builder Center guide to a self-managed kubeadm cluster is a useful second opinion on the init and join sequence.

Steps 1 to 4: Prepare Tooling and the OpenTofu Project

Step 1: Pin Your Versions and Node Layout

Decide the numbers before you write code. Pick a Kubernetes minor (this tutorial uses 1.37), a cluster name, a region, and node sizes. Control plane nodes need at least the minimum resources kubeadm documents for a control plane, and workers should match whatever your workloads need. Droplet prices vary by size and change over time, so check the current DigitalOcean pricing page before you apply. A short-lived lab costs very little if you destroy it the same day.

Write the choices into a scratch file now. You will turn them into variables in Step 4, and having them in one place prevents the most common source of drift: a worker that installed a different minor version than the control plane. Kubernetes only supports workers that run at most the control plane’s version, so a mismatch in the wrong direction breaks joins.

Step 2: Install OpenTofu 1.13 and the Local Tools

Install OpenTofu with the official installer script on Debian or Ubuntu. Download it first so you can read it before you run it.

curl --proto '=https' --tlsv1.2 -fsSL https://get.opentofu.org/install-opentofu.sh -o install-opentofu.sh
less install-opentofu.sh
chmod +x install-opentofu.sh
./install-opentofu.sh --install-method deb
rm install-opentofu.sh

sudo apt-get install -y jq openssh-client
tofu version

On macOS, brew install opentofu works, but note that OpenTofu 1.13 builds require macOS 13 Ventura or later. Windows users should run everything inside WSL, partly because the bootstrap script is Bash and partly because OpenTofu 1.13 dropped WinRM provisioner support. The release post also says 1.13 is the final series with official builds for legacy 32-bit platforms, and that it adds official Windows ARM64 packages. Expect output similar to this:

OpenTofu v1.13.0
on linux_amd64

Your patch number may be newer, and that is fine. Anything at 1.13.0 or above satisfies the required_version constraint we set in Step 4. Install kubectl and Helm with your usual package manager too. You only need kubectl locally after the tunnel is up, and Helm for the network plugin in Step 12.

Step 3: Create the API Token and an SSH Key

In the DigitalOcean control panel, create a personal access token with read and write scope and copy it once. Then generate a dedicated SSH key pair for this lab so you can delete it afterwards without touching your daily keys. Export the token and your admin IP as environment variables, which keeps both out of your Git history.

ssh-keygen -t ed25519 -f ~/.ssh/k8s-lab -C "k8s-lab" -N ""

export TF_VAR_do_token="paste-your-token-here"
export TF_VAR_admin_cidr="$(curl -s https://api.ipify.org)/32"
echo "$TF_VAR_admin_cidr"

The last command should print a single address ending in /32. If it prints an HTML error page, your network blocks the lookup, so type your public address by hand instead. Treat the token like a password. Anyone who holds it can create and delete resources in your account.

Step 4: Scaffold the Project, Providers, and Variables

Create a directory and four starter files. The provider block pins the OpenTofu version and pulls the DigitalOcean provider from the OpenTofu registry. The variables file holds every number you decided in Step 1.

# versions.tf
terraform {
  required_version = ">= 1.13.0"

  required_providers {
    digitalocean = {
      source  = "digitalocean/digitalocean"
      version = "~> 2.0"
    }
  }
}

provider "digitalocean" {
  token = var.do_token
}

# variables.tf
variable "do_token" {
  type      = string
  sensitive = true
}

variable "admin_cidr" {
  type        = string
  description = "Your public IP in CIDR form, allowed to SSH to the nodes"
}

variable "cluster_name" {
  type    = string
  default = "k8s"
}

variable "region" {
  type    = string
  default = "nyc3"
}

variable "k8s_minor" {
  type        = string
  default     = "1.37"
  description = "Kubernetes minor version for the pkgs.k8s.io package stream"
}

variable "cp_count" {
  type    = number
  default = 3
}

variable "worker_count" {
  type    = number
  default = 2
}

variable "cp_size" {
  type    = string
  default = "s-2vcpu-4gb"
}

variable "worker_size" {
  type    = string
  default = "s-2vcpu-4gb"
}

variable "ssh_public_key_path" {
  type    = string
  default = "~/.ssh/k8s-lab.pub"
}

Run tofu init in the directory. It downloads the provider and writes a lock file named .terraform.lock.hcl. Commit that file. It pins the exact provider build your team tested, and OpenTofu 1.12 improved how checksums are handled in it, which cuts the lock-file friction that used to annoy multi-platform teams.

tofu init

Initializing the backend...
Initializing provider plugins...
- Finding digitalocean/digitalocean versions matching "~> 2.0"...
- Installing digitalocean/digitalocean...

OpenTofu has been successfully initialized!

If you see that last line, the toolchain works and you can move on to the infrastructure. Run tofu validate as well. It catches typos in variable names before they cost you an apply cycle.

Steps 5 to 8: Provision the Network, Nodes, and Load Balancer

Step 5: Define the VPC, Tag, and Cloud Firewall

Start with the private network. The VPC range must not overlap the pod or service ranges you pick later. We use 10.20.0.0/24 for the VPC, 10.244.0.0/16 for pods, and 10.96.0.0/12 for services, so all three are disjoint. The firewall applies to every node through a tag, which means new nodes inherit the rules automatically.

# network.tf
resource "digitalocean_ssh_key" "lab" {
  name       = "${var.cluster_name}-lab"
  public_key = file(pathexpand(var.ssh_public_key_path))
}

resource "digitalocean_vpc" "k8s" {
  name     = "${var.cluster_name}-vpc"
  region   = var.region
  ip_range = "10.20.0.0/24"
}

resource "digitalocean_tag" "k8s" {
  name = "${var.cluster_name}-node"
}

resource "digitalocean_firewall" "nodes" {
  name = "${var.cluster_name}-nodes"
  tags = [digitalocean_tag.k8s.id]

  inbound_rule {
    protocol         = "tcp"
    port_range       = "22"
    source_addresses = [var.admin_cidr]
  }

  inbound_rule {
    protocol         = "tcp"
    port_range       = "1-65535"
    source_addresses = [digitalocean_vpc.k8s.ip_range]
  }

  inbound_rule {
    protocol         = "udp"
    port_range       = "1-65535"
    source_addresses = [digitalocean_vpc.k8s.ip_range]
  }

  inbound_rule {
    protocol         = "icmp"
    source_addresses = [digitalocean_vpc.k8s.ip_range]
  }

  outbound_rule {
    protocol              = "tcp"
    port_range            = "1-65535"
    destination_addresses = ["0.0.0.0/0", "::/0"]
  }

  outbound_rule {
    protocol              = "udp"
    port_range            = "1-65535"
    destination_addresses = ["0.0.0.0/0", "::/0"]
  }

  outbound_rule {
    protocol              = "icmp"
    destination_addresses = ["0.0.0.0/0", "::/0"]
  }
}

Inside the VPC the rules are wide open, which keeps the lab simple. In production you would narrow them to the ports kubeadm documents. The table below lists the ones that matter, so you can tighten the firewall once the cluster works.

PortProtocolUsed byNeeded on
6443TCPKubernetes API serverControl plane
2379-2380TCPetcd client and peer trafficControl plane
10250TCPkubelet APIAll nodes
Not listedTCPkube-controller-managerControl plane
Not listedTCPkube-schedulerControl plane
Not listedTCPNodePort servicesWorkers

If your home IP changes, update TF_VAR_admin_cidr and run tofu apply again. Only the firewall changes, and the nodes keep running.

Step 6: Write the cloud-init Node Preparation

Every node needs the same base: kernel modules, sysctl settings, containerd with the systemd cgroup driver, and the Kubernetes packages held at one minor version. Cloud-init does this at first boot, so OpenTofu stays declarative and you never SSH in to install anything by hand. Save this as cloud-init.yaml.tftpl. The ${k8s_minor} placeholders get filled in by OpenTofu’s templatefile function.

#cloud-config
package_update: true
packages:
  - apt-transport-https
  - ca-certificates
  - curl
  - gpg
  - containerd
write_files:
  - path: /etc/modules-load.d/k8s.conf
    content: |
      overlay
      br_netfilter
  - path: /etc/sysctl.d/99-k8s.conf
    content: |
      net.bridge.bridge-nf-call-iptables  = 1
      net.bridge.bridge-nf-call-ip6tables = 1
      net.ipv4.ip_forward                 = 1
runcmd:
  - modprobe overlay
  - modprobe br_netfilter
  - sysctl --system
  - swapoff -a
  - mkdir -p /etc/containerd /etc/apt/keyrings
  - containerd config default > /etc/containerd/config.toml
  - sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
  - systemctl enable containerd
  - systemctl restart containerd
  - curl -fsSL https://pkgs.k8s.io/core:/stable:/v${k8s_minor}/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:/v${k8s_minor}/deb/ /" > /etc/apt/sources.list.d/kubernetes.list
  - apt-get update
  - apt-get install -y kubelet kubeadm kubectl
  - apt-mark hold kubelet kubeadm kubectl
  - systemctl enable kubelet

Three lines carry most of the weight. The sed command flips containerd to the systemd cgroup driver, which must match the kubelet. The apt-mark hold line stops an unattended apt upgrade from jumping the cluster to a new minor version. And the package repository URL encodes the minor version, so changing k8s_minor is the only edit you ever make to move streams.

Step 7: Declare the Droplets and the Internal Load Balancer

Now the machines themselves. Control planes and workers share an image and user data. The load balancer is internal, so it takes a private address from the VPC and never appears on the public internet. The ignore_changes lifecycle rule is deliberate: without it, editing the cloud-init file would force OpenTofu to destroy and rebuild every running node.

# nodes.tf
locals {
  user_data = templatefile("${path.module}/cloud-init.yaml.tftpl", {
    k8s_minor = var.k8s_minor
  })
}

resource "digitalocean_droplet" "cp" {
  count     = var.cp_count
  name      = "${var.cluster_name}-cp-${count.index}"
  image     = "ubuntu-24-04-x64"
  region    = var.region
  size      = var.cp_size
  vpc_uuid  = digitalocean_vpc.k8s.id
  ssh_keys  = [digitalocean_ssh_key.lab.fingerprint]
  user_data = local.user_data
  tags      = [digitalocean_tag.k8s.id]

  lifecycle {
    ignore_changes = [user_data]
  }
}

resource "digitalocean_droplet" "worker" {
  count     = var.worker_count
  name      = "${var.cluster_name}-worker-${count.index}"
  image     = "ubuntu-24-04-x64"
  region    = var.region
  size      = var.worker_size
  vpc_uuid  = digitalocean_vpc.k8s.id
  ssh_keys  = [digitalocean_ssh_key.lab.fingerprint]
  user_data = local.user_data
  tags      = [digitalocean_tag.k8s.id]

  lifecycle {
    ignore_changes = [user_data]
  }
}

resource "digitalocean_loadbalancer" "api" {
  name     = "${var.cluster_name}-api"
  region   = var.region
  vpc_uuid = digitalocean_vpc.k8s.id
  network  = "INTERNAL"

  forwarding_rule {
    entry_port      = 6443
    entry_protocol  = "tcp"
    target_port     = 6443
    target_protocol = "tcp"
  }

  healthcheck {
    port                     = 6443
    protocol                 = "tcp"
    check_interval_seconds   = 10
    response_timeout_seconds = 5
    unhealthy_threshold      = 3
    healthy_threshold        = 2
  }

  droplet_ids = digitalocean_droplet.cp[*].id
}

# outputs.tf
output "api_lb_private_ip" {
  value = digitalocean_loadbalancer.api.ip
}

output "cp_public_ips" {
  value = digitalocean_droplet.cp[*].ipv4_address
}

output "cp_private_ips" {
  value = digitalocean_droplet.cp[*].ipv4_address_private
}

output "worker_public_ips" {
  value = digitalocean_droplet.worker[*].ipv4_address
}

output "worker_private_ips" {
  value = digitalocean_droplet.worker[*].ipv4_address_private
}

The health check will show all three control planes as unhealthy until kubeadm starts the API server. That is normal. The official kubeadm high availability guidance expects a refused connection on the load balancer address at this stage, and the check turns green node by node as each API server comes up.

Step 8: Plan and Apply

Always read the plan before you apply. It should show ten resources: the SSH key, the VPC, the tag, the firewall, three control planes, two workers, and the load balancer.

tofu plan -out=tfplan
tofu apply tfplan
Plan: 10 to add, 0 to change, 0 to destroy.

digitalocean_droplet.cp[0]: Creation complete after 38s
digitalocean_loadbalancer.api: Creation complete after 1m52s

Apply complete! Resources: 10 added, 0 changed, 0 destroyed.

Outputs:
api_lb_private_ip = "10.20.0.7"
cp_private_ips = [
  "10.20.0.2",
  "10.20.0.3",
  "10.20.0.4",
]

Your addresses and timings will differ, but the shape should match. Cloud-init keeps running for a minute or two after the Droplets report ready, because it still has packages to download. Do not start kubeadm yet. Step 9 waits for cloud-init to finish on every node and proves the machines are ready. While you wait, decide what to deploy first. A good candidate is the app from our Django production blog tutorial, which gives the scheduler a real web workload with a database behind it.

Why cgroup v2 and the systemd Driver Matter Right Now

Before you bootstrap, understand the one setting that breaks more first-time clusters than any other. The kubelet and the container runtime must agree on which cgroup driver they use. Kubernetes recommends cgroup v2, and for kubeadm-managed clusters the recommended kubelet cgroup driver is systemd, matching the container runtime. The kubeadm documentation recommends exactly that pairing, and our cloud-init file sets it in containerd while the kubeadm config in Step 10 sets it in the kubelet.

The timing is not a coincidence. Support for cgroup v2 has been stable since Kubernetes 1.25, and cgroup v1 management entered maintenance mode with 1.31. The Kubernetes project’s October 6, 2026 article on the shift to cgroup v2 recommends migrating, notes that 1.36 still keeps v1 as a fallback, and states that the fallback is scheduled for removal in 1.38. If you inherit older nodes that boot with cgroup v1, treat that as upgrade debt now. A cluster that works today can refuse to start its kubelets after the 1.38 upgrade.

The check takes one command per node, and Step 9 runs it for you. The output cgroup2fs means you are on v2. The output tmpfs means v1, and you should rebuild that node from a newer image rather than patching kernel boot flags.

Steps 9 to 11: Run kubeadm Init and Join the Nodes

Step 9: Verify Every Node Before Bootstrapping

From here on, a single Bash script drives the cluster. Create bootstrap.sh and start with the helper functions and the readiness check. The script reads the node addresses straight from OpenTofu’s JSON output, so there is nothing to copy by hand.

#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"

K8S_MINOR="${K8S_MINOR:-1.37}"
SSH_KEY="${SSH_KEY:-$HOME/.ssh/k8s-lab}"
SSH_OPTS="-o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 -i $SSH_KEY"

out() { tofu output -json | jq -r "$1"; }
run() { local host=$1; shift; ssh $SSH_OPTS root@"$host" "$@"; }

LB_IP=$(out '.api_lb_private_ip.value')
mapfile -t CP_PUB  < <(out '.cp_public_ips.value[]')
mapfile -t CP_PRIV < <(out '.cp_private_ips.value[]')
mapfile -t WK_PUB  < <(out '.worker_public_ips.value[]')
mapfile -t WK_PRIV < <(out '.worker_private_ips.value[]')
CP0_PUB="${CP_PUB[0]}"

echo "== Step 9: waiting for cloud-init and checking nodes"
for h in "${CP_PUB[@]}" "${WK_PUB[@]}"; do
  run "$h" 'cloud-init status --wait >/dev/null'
  echo "-- $h"
  run "$h" 'stat -fc %T /sys/fs/cgroup; kubeadm version -o short; grep SystemdCgroup /etc/containerd/config.toml'
done
== Step 9: waiting for cloud-init and checking nodes
-- 203.0.113.11
cgroup2fs
v1.37.x
            SystemdCgroup = true

Every node should print the same three lines: cgroup2fs, a v1.37 version (your patch number will differ), and SystemdCgroup = true. If one node disagrees, stop and fix it now. Joining a misconfigured node costs far more time than rebuilding it.

Step 10: Initialize the First Control Plane

DigitalOcean Droplets have both a public and a private address, and the kubelet picks the default-route address unless you tell it otherwise. That would put public IPs into your node list and send etcd traffic over the internet. The fix is a one-line file per node that pins --node-ip to the private address. Append this block to bootstrap.sh, then the kubeadm configuration.

echo "== Step 10: pinning node IPs and initializing the first control plane"
set_node_ip() { run "$1" "echo 'KUBELET_EXTRA_ARGS=--node-ip=$2' > /etc/default/kubelet"; }
for i in "${!CP_PUB[@]}"; do set_node_ip "${CP_PUB[$i]}" "${CP_PRIV[$i]}"; done
for i in "${!WK_PUB[@]}"; do set_node_ip "${WK_PUB[$i]}" "${WK_PRIV[$i]}"; done

run "$CP0_PUB" "cat > /root/kubeadm-init.yaml" <<EOF
apiVersion: kubeadm.k8s.io/v1beta4
kind: InitConfiguration
localAPIEndpoint:
  advertiseAddress: ${CP_PRIV[0]}
  bindPort: 6443
nodeRegistration:
  criSocket: unix:///run/containerd/containerd.sock
---
apiVersion: kubeadm.k8s.io/v1beta4
kind: ClusterConfiguration
kubernetesVersion: stable-${K8S_MINOR}
controlPlaneEndpoint: "${LB_IP}:6443"
networking:
  podSubnet: 10.244.0.0/16
  serviceSubnet: 10.96.0.0/12
apiServer:
  certSANs:
    - 127.0.0.1
    - ${LB_IP}
---
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
cgroupDriver: systemd
EOF

run "$CP0_PUB" 'kubeadm init --config /root/kubeadm-init.yaml --upload-certs | tee /root/kubeadm-init.log'

The configuration uses the v1beta4 kubeadm API, which the current documentation still describes. Three fields deserve a closer look. controlPlaneEndpoint points at the load balancer, not at the first node, which is what makes later control planes joinable. certSANs adds 127.0.0.1 so your SSH tunnel passes TLS verification in Step 13. And --upload-certs stores the control plane certificates in an encrypted cluster secret for two hours so other control planes can fetch them. A successful run ends like this:

Your Kubernetes control-plane has initialized successfully!

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

  kubeadm join 10.20.0.7:6443 --token abcdef.0123456789abcdef \
        --discovery-token-ca-cert-hash sha256:... \
        --control-plane --certificate-key ...

Step 11: Join the Other Control Planes and the Workers

The script now captures the certificate key from the init log and builds a fresh join command. Control planes join one at a time, because each new etcd member must sync before the next one arrives. Workers can join in any order.

echo "== Step 11: joining control planes and workers"
CERT_KEY=$(run "$CP0_PUB" "grep -m1 -e '--certificate-key' /root/kubeadm-init.log | awk '{print \$NF}'")
JOIN=$(run "$CP0_PUB" 'kubeadm token create --print-join-command')

for i in "${!CP_PUB[@]}"; do
  if [ "$i" -gt 0 ]; then
    run "${CP_PUB[$i]}" "$JOIN --control-plane --certificate-key $CERT_KEY --apiserver-advertise-address ${CP_PRIV[$i]}"
  fi
done

for h in "${WK_PUB[@]}"; do
  run "$h" "$JOIN"
done

Each join prints a long preflight log and ends with a line saying the node has joined the cluster. If a control plane join stalls on etcd, wait a full minute before you intervene. etcd learner promotion is slow on small Droplets. When the loop finishes, the load balancer health check should show all three control planes healthy in the DigitalOcean console.

Steps 12 and 13: Install the Network Plugin and Reach the API Safely

Step 12: Fetch the Kubeconfig and Open the SSH Tunnel

The admin kubeconfig points at the load balancer’s private address, which your laptop cannot reach. Rewrite the server line to 127.0.0.1 and forward that local port through the first control plane. The tunnel runs in the background until you kill it.

echo "== Step 12: kubeconfig and tunnel"
scp $SSH_OPTS root@"$CP0_PUB":/etc/kubernetes/admin.conf ./admin.conf
sed -i "s#https://${LB_IP}:6443#https://127.0.0.1:6443#" admin.conf
chmod 600 admin.conf

ssh $SSH_OPTS -f -N -L 6443:"${LB_IP}":6443 root@"$CP0_PUB"
export KUBECONFIG="$PWD/admin.conf"
kubectl get nodes
NAME           STATUS     ROLES           AGE   VERSION
k8s-cp-0       NotReady   control-plane   6m    v1.37.x
k8s-cp-1       NotReady   control-plane   4m    v1.37.x
k8s-cp-2       NotReady   control-plane   3m    v1.37.x
k8s-worker-0   NotReady   <none>          2m    v1.37.x
k8s-worker-1   NotReady   <none>          2m    v1.37.x

Every node reads NotReady, and that is correct. Without a pod network, the kubelet reports that the CNI plugin has not initialized. CoreDNS also sits in Pending for the same reason. Install the network plugin and the whole picture flips within a minute or two.

Step 13: Install Cilium and Run a Smoke Test

Cilium gives you pod networking and network policy in one install. Look up the chart versions with Helm and pick the newest stable one yourself, since this tutorial deliberately does not hard-code a version it cannot verify for you today. The one setting that must match your kubeadm config is the pod CIDR.

helm repo add cilium https://helm.cilium.io/
helm repo update
helm search repo cilium/cilium --versions | head -5

export CILIUM_VERSION="PICK_FROM_THE_LIST_ABOVE"
helm install cilium cilium/cilium \
  --version "$CILIUM_VERSION" \
  --namespace kube-system \
  --set 'ipam.operator.clusterPoolIPv4PodCIDRList={10.244.0.0/16}'

kubectl -n kube-system rollout status ds/cilium --timeout=300s
kubectl get nodes
NAME           STATUS   ROLES           AGE   VERSION
k8s-cp-0       Ready    control-plane   11m   v1.37.x
k8s-cp-1       Ready    control-plane   9m    v1.37.x
k8s-cp-2       Ready    control-plane   8m    v1.37.x
k8s-worker-0   Ready    <none>          7m    v1.37.x
k8s-worker-1   Ready    <none>          7m    v1.37.x

Five nodes, all Ready. Now prove the cluster does real work. Deploy three replicas of a web server, expose them, and curl the service from a throwaway pod. Then check that etcd sees all three members.

kubectl create deployment web --image=nginx --replicas=3
kubectl expose deployment web --port=80
kubectl rollout status deployment/web
kubectl run curl --rm -it --image=curlimages/curl --restart=Never -- curl -s -o /dev/null -w "%{http_code}\n" http://web

kubectl -n kube-system exec etcd-k8s-cp-0 -- etcdctl \
  --endpoints=https://127.0.0.1:2379 \
  --cacert=/etc/kubernetes/pki/etcd/ca.crt \
  --cert=/etc/kubernetes/pki/etcd/server.crt \
  --key=/etc/kubernetes/pki/etcd/server.key \
  member list -w table

The curl pod should print 200, and the member table should list three started members. For a final high availability test, power off k8s-cp-2 from the console and run kubectl get nodes again. The command should still answer, because the load balancer drops the dead backend after three failed checks. Boot the node again afterwards and watch it rejoin. With the smoke test green, you have somewhere to run real services. A mobile team can point a client at an API deployed here, and our Flutter production app tutorial covers the client side of that pairing.

Common Pitfalls and How to Avoid Them

Most failed kubeadm builds trace back to a short list of mistakes. Read these before your first apply, and you will skip hours of log reading.

  1. Letting the kubelet pick the public IP. On cloud VMs with two interfaces, the kubelet binds to the default-route address. Your nodes then advertise public IPs, etcd peers talk across the internet, and the firewall blocks them. Step 10 fixes this with --node-ip, and you should check the INTERNAL-IP column of kubectl get nodes -o wide after every build.
  2. Mismatched cgroup drivers. If containerd uses cgroupfs while the kubelet uses systemd, pods crash-loop at random. Set both to systemd, and verify the containerd line in Step 9 on every node.
  3. Skipping apt-mark hold. An unattended upgrade can bump the kubelet one minor ahead of the control plane. Kubernetes does not support a kubelet newer than the API server, so the node drops out. Hold the three packages and upgrade by plan only.
  4. Overlapping CIDRs. If the pod range overlaps the VPC range, routing breaks in ways that look like random timeouts. Write the three ranges on paper before you apply, and keep them disjoint.
  5. Choosing two control planes. Two etcd members cannot survive a failure, so two nodes is strictly worse than one for availability. Run one for a throwaway lab or three for anything that matters.
  6. Exposing the API server publicly. A public 6443 invites constant scanning. The internal load balancer and SSH tunnel in this tutorial keep the endpoint private at no extra cost.
  7. Forgetting to destroy the lab. Droplets and load balancers bill by the hour. Set a calendar reminder when you apply, and run the teardown script from the complete project when you finish.
  8. Ignoring the state file. The local terraform.tfstate file holds every resource ID and can hold sensitive values. Keep it out of Git, and move it to an encrypted remote backend before a second person touches the project.

Troubleshooting: 10 Failures and Their Fixes

When something breaks, work from the outside in: provider, machine, runtime, kubelet, then cluster. Each item below names the symptom, the likely cause, and the fix.

  1. tofu init cannot download the provider. Your network blocks the registry or a proxy rewrites TLS. Test with curl -I https://registry.opentofu.org, set HTTPS_PROXY if needed, and rerun init. Delete the .terraform directory if a half-finished download is cached.
  2. Authentication error from the DigitalOcean API. The token is missing, expired, or read-only. Confirm echo ${TF_VAR_do_token:0:6} prints the start of your token, and recreate the token with write scope if it does not.
  3. SSH times out right after apply. Either the Droplet is still booting or the firewall blocks you. Compare echo $TF_VAR_admin_cidr with your current public IP. If they differ, export the new value and apply again.
  4. kubeadm: command not found. Cloud-init has not finished or a package step failed. Run cloud-init status --long and read /var/log/cloud-init-output.log. A failed curl to pkgs.k8s.io usually means the minor version in k8s_minor does not exist as a package stream yet.
  5. Preflight error on ip_forward or bridge-nf-call-iptables. The sysctl file did not load. Run sysctl --system as root, confirm lsmod | grep br_netfilter shows the module, and rerun kubeadm.
  6. kubeadm init hangs at “waiting for the kubelet”. The kubelet cannot start its static pods. Run journalctl -u kubelet -n 100 --no-pager. A cgroup driver mismatch and a swap-enabled node are the two usual causes. Fix the cause, then run kubeadm reset -f and init again.
  7. Connection refused to the load balancer during init. Expected before the first API server starts. If it persists after init succeeds, check the load balancer health in the console and confirm the firewall allows TCP 6443 inside the VPC.
  8. Control plane join fails with “certificate key” or “token” errors. The uploaded certificates expire after two hours and tokens after 24 hours. On the first node, run kubeadm init phase upload-certs --upload-certs for a new key and kubeadm token create --print-join-command for a new token.
  9. Nodes stay NotReady with “cni plugin not initialized”. No network plugin is running. Check kubectl -n kube-system get pods. If Cilium pods crash, read their logs and confirm the pod CIDR in the Helm values matches podSubnet in the kubeadm config.
  10. x509: certificate is valid for ..., not 127.0.0.1 through the tunnel. The API server certificate lacks the loopback SAN. Add 127.0.0.1 to certSANs before init, or regenerate the certificate on every control plane with kubeadm certs renew apiserver after updating the config.

One more habit saves time: keep /root/kubeadm-init.log and the output of tofu output in your notes. Nearly every support thread for kubeadm starts with someone asking for those two artifacts.

Advanced Tips: Upgrades, Backups, Swap, and AI Workloads

Plan upgrades before you need them. kubeadm upgrades one minor version at a time. On the first control plane, run kubeadm upgrade plan, then kubeadm upgrade apply with the target patch release. On the other control planes and each worker, run kubeadm upgrade node and then upgrade the kubelet package. Drain each node first. Kubernetes 1.34 reportedly reaches end of life on October 27, 2026, and Agency 42 publishes a pre-flight checklist for the move to 1.35 that is worth reading if you run that line. The project also has 1.38 on its calendar for December 16, 2026, though a scheduled date is not a release.

Snapshot etcd on a schedule. A cluster you cannot restore is a liability. Take a snapshot from inside the etcd pod, copy it off the node, and store it somewhere that does not share the cluster’s failure domain.

kubectl -n kube-system exec etcd-k8s-cp-0 -- etcdctl \
  --endpoints=https://127.0.0.1:2379 \
  --cacert=/etc/kubernetes/pki/etcd/ca.crt \
  --cert=/etc/kubernetes/pki/etcd/server.crt \
  --key=/etc/kubernetes/pki/etcd/server.key \
  snapshot save /var/lib/etcd/snapshot.db

scp -i ~/.ssh/k8s-lab root@CP0_PUBLIC_IP:/var/lib/etcd/snapshot.db ./etcd-$(date +%F).db

Move state off your laptop. Once a teammate joins, switch to a remote backend and turn on OpenTofu’s native state encryption. The 1.12 release also added a dynamic prevent_destroy setting, so you can protect the load balancer and control plane Droplets from an accidental tofu destroy in production workspaces while still allowing it in your lab.

Treat node swap as an opt-in feature. The Kubernetes project reportedly describes node swap as generally available from 1.34 onward, and published an article on it on October 5, 2026. Our cloud-init disables swap, which is the safe default. If you want to experiment, set failSwapOn: false and a limited swap behavior in the KubeletConfiguration, and measure latency on a canary node first. Availability of a feature does not mean every workload should use it.

Plan for AI scheduling. A CNCF post from October 19, 2026 reports that workload-aware scheduling APIs, including Workload and PodGroup, are in beta and disabled by default, with provisioning requests serving as a bridge toward group capacity provisioning. Read how AI workloads are reshaping Kubernetes autoscaling before you size GPU pools on a cluster like this one. The SIG Apps team has also described an Agent Sandbox subproject for AI-oriented workloads. If you plan to host a model-backed product, our write-up on choosing an LLM for a health AI coach shows the application side of that decision.

Do not skip observability. Hightower raised the same point in that older Go Time episode when he asked who is going to collect the logs and push them to a central place. A bare kubeadm cluster ships with none of that. Add metrics, log shipping, and an alert on etcd leader changes before you put real traffic on it.

The Complete Working Project

Everything you wrote fits in one small repository. The layout below is the whole project. The three Bash files tie the steps together, so a rebuild is one command.

k8s-kubeadm-opentofu/
  versions.tf             # Step 4
  variables.tf            # Step 4
  network.tf              # Step 5
  cloud-init.yaml.tftpl   # Step 6
  nodes.tf                # Step 7 (droplets, load balancer, outputs)
  bootstrap.sh            # Steps 9-12 (checks, init, joins, kubeconfig, tunnel)
  up.sh                   # one-command build
  down.sh                 # one-command teardown
  .gitignore
  .terraform.lock.hcl     # commit this one
# up.sh
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
: "${TF_VAR_do_token:?export TF_VAR_do_token first}"
: "${TF_VAR_admin_cidr:?export TF_VAR_admin_cidr first}"
: "${CILIUM_VERSION:?export CILIUM_VERSION first}"

tofu init -input=false
tofu apply -auto-approve
./bootstrap.sh

export KUBECONFIG="$PWD/admin.conf"
helm repo add cilium https://helm.cilium.io/ --force-update
helm upgrade --install cilium cilium/cilium \
  --version "$CILIUM_VERSION" --namespace kube-system \
  --set 'ipam.operator.clusterPoolIPv4PodCIDRList={10.244.0.0/16}'
kubectl -n kube-system rollout status ds/cilium --timeout=300s
kubectl get nodes -o wide

# down.sh
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
pkill -f "ssh .* -L 6443:" || true
tofu destroy -auto-approve
rm -f admin.conf tfplan

# .gitignore
.terraform/
*.tfstate
*.tfstate.*
admin.conf
tfplan

Run chmod +x up.sh down.sh bootstrap.sh, export the three variables, and call ./up.sh. The first successful run proves the recipe. After that, commit the project, and treat it as the single source of truth for the cluster. Change a variable, review the plan, apply, and rerun the bootstrap only when you add nodes.

Cleanup and Cost Control

Run ./down.sh when you finish. It kills the tunnel and destroys all ten resources. Then open the DigitalOcean console and confirm that no Droplets, load balancers, or VPCs remain, because a failed destroy can leave orphans that keep billing. Prices vary by Droplet size and load balancer tier, so read the current pricing page rather than trusting a number from an article. A three-hour lab on small nodes costs little, and a forgotten cluster costs real money by the end of the month.

If you want a cheaper rehearsal, set cp_count=1 and worker_count=1. You lose high availability, but every other step stays identical. That is a good way to debug changes to the bootstrap script before you spend the time on a full build.

Frequently Asked Questions

What is kubeadm used for?

kubeadm bootstraps a Kubernetes cluster on machines you already have. It generates certificates, starts the control plane as static pods, and joins other nodes. It does not create the machines or install a network plugin, which is why this tutorial pairs it with OpenTofu and Cilium.

Is a kubeadm cluster production-ready?

Yes, if you add what kubeadm leaves out. That means three or more control planes behind a load balancer, etcd backups, node patching, monitoring, and a tested upgrade routine. The official documentation positions kubeadm as a tool for creating production clusters, but the operating burden is yours.

Can I use Terraform instead of OpenTofu?

The code is standard HCL with the DigitalOcean provider, so most of it should run under either tool. This tutorial targets OpenTofu 1.13 and sets required_version accordingly. Change that constraint if you use another runner, and expect small differences in lock-file handling and state features.

Which Kubernetes version should I install?

Install the newest stable minor that your tooling and add-ons support. Check the releases page on kubernetes.io on the day you build, because project pages and cloud announcements do not always agree on which minor counts as current. Set k8s_minor once and keep every node on the same stream.

How many control plane nodes do I need?

Use three for high availability. etcd needs a majority of members to accept writes, so three tolerate one failure and five tolerate two. One node works for a lab, and two nodes give you worse availability than one.

Can I run this on AWS, Azure, Hetzner, or bare metal?

Yes. Swap the provider block and the resource definitions, then keep the same cloud-init file and bootstrap script. The script only needs SSH access, a private IP per node, and one stable private endpoint for the API server.

Why does the cluster use cgroup v2 and the systemd driver?

cgroup v2 has been stable in Kubernetes since 1.25, and the project is moving away from v1, with the fallback scheduled for removal in 1.38. Matching systemd drivers in containerd and the kubelet avoid the crash loops that a mismatch causes.

How do I tear everything down?

Run ./down.sh, which closes the tunnel and calls tofu destroy. Check the DigitalOcean console afterward for leftover Droplets or load balancers.

Sofia Lindström

Sofia Lindström

Editor-in-Chief

Sofia Lindström is the Editor-in-Chief at Tech Insider, where she leads editorial strategy and oversees coverage across AI, cybersecurity, and enterprise technology. With over a decade in Swedish tech journalism, she previously served as technology editor at Dagens Industri and covered the Nordic startup ecosystem for Breakit. Sofia holds an MSc in Media Technology from KTH Royal Institute of Technology and is a frequent speaker at Web Summit and Slush. She is passionate about making complex technology accessible to business leaders.

View all articles