Scope. This article builds a highly available Kubernetes 1.36 cluster with three control-plane nodes, two workers, and HAProxy as the API load balancer. It assumes you have already built a single control-plane cluster following the basic kubeadm guide. The focus is on what changes for HA: the load balancer, the etcd quorum, the two different join commands, and the failure modes.
What Changes from a Single Control Plane
A single control-plane cluster has one API server, one etcd instance, one scheduler, and one controller manager. If that node goes down, the entire control plane is lost. The worker nodes keep running their pods, but no new scheduling, scaling, or API access is possible until the control plane recovers.
A highly available cluster distributes the control plane across three nodes. Each runs its own API server, its own etcd member, and its own scheduler and controller manager (with active-standby election). A load balancer sits in front of the three API servers and routes traffic to whichever is available. If one control-plane node fails, the remaining two maintain etcd quorum and API availability.
The rest of the cluster — containerd, kubelet, Cilium, the kubeadm packages — is installed exactly as in the basic guide. This article covers only the differences.
Why Three, Not Two
Etcd requires a majority of its members to agree on every write. This is the Raft consensus protocol. With two members, the majority is two — both must be available for any write to succeed. Losing one member loses the majority and the cluster becomes read-only.
With three members, the majority is two. One node can fail and the remaining two still form a majority. This is the smallest topology that provides meaningful fault tolerance.
Five members tolerate two failures but add latency to every write (three nodes must acknowledge). For most clusters, three control planes is the right number.
The Architecture
Six nodes total:
| Role | Count | Purpose |
|---|---|---|
| Load balancer | 1 | HAProxy, forwards port 6443 to the three API servers |
| Control plane | 3 | API server, etcd, scheduler, controller manager |
| Worker | 2 | Kubelet, pod scheduling |
All five Kubernetes nodes (three CP, two workers) need the same base preparation from the basic kubeadm guide: swap disabled, kernel modules loaded, containerd with SystemdCgroup, and the kubeadm/kubelet/kubectl packages pinned.
The load balancer node runs only HAProxy. It does not run kubelet and is not part of the Kubernetes cluster.
HAProxy: What It Balances and What It Does Not
HAProxy listens on port 6443 and forwards TCP connections to the three API servers using round-robin with health checks. The configuration is minimal:
frontend k8s_api
bind *:6443
mode tcp
default_backend k8s_api_backend
backend k8s_api_backend
mode tcp
balance roundrobin
option tcp-check
default-server inter 3s fall 3 rise 2
server cp01 CP01_IP:6443 check
server cp02 CP02_IP:6443 check
server cp03 CP03_IP:6443 check
Replace CP01_IP, CP02_IP, and CP03_IP with the actual IP addresses of your control-plane nodes.
HAProxy balances the Kubernetes API only. It does not balance pod traffic, NodePort services, or ingress. Pod-to-pod communication goes through the CNI (Cilium). NodePort traffic goes directly to the worker node’s IP. Ingress traffic goes through an ingress controller deployed inside the cluster. HAProxy handles none of these.
The health check (tcp-check connect port 6443) verifies that the API server is accepting TCP connections. If a control-plane node goes down, HAProxy marks it as failed after three consecutive check failures (9 seconds) and stops sending traffic to it.
Initializing the First Control Plane
This runs only on the first control-plane node.
The kubeadm configuration for HA differs from the basic setup in one critical field: controlPlaneEndpoint points to the load balancer, not to the local node.
apiVersion: kubeadm.k8s.io/v1beta4 kind: ClusterConfiguration kubernetesVersion: v1.36.4 controlPlaneEndpoint: "HAPROXY_IP:6443" networking: podSubnet: 10.244.0.0/16 serviceSubnet: 10.96.0.0/12 --- apiVersion: kubelet.config.k8s.io/v1beta1 kind: KubeletConfiguration cgroupDriver: systemd
Replace HAPROXY_IP with the IP address or DNS name of the load balancer. Every component in the cluster will use this address to reach the API. It must resolve correctly from every node before you proceed.
Initialize with certificate upload enabled:
kubeadm init --config=/root/kubeadm-ha.yaml --upload-certs \ | tee /root/kubeadm-init.out
The --upload-certs flag encrypts the control-plane certificates and stores them in a Kubernetes Secret. The other control-plane nodes will download these certificates during their join. Save the entire output. It contains two different join commands — one for control planes and one for workers.
The kubelet-to-localhost Redirect
After initialization, the kubelet on the first control-plane node is configured to reach the API through HAProxy. This creates a circular dependency: if HAProxy goes down, the kubelet cannot reach the API server that runs on the same node.
The fix is to redirect the kubelet to use the local API server directly:
sudo sed -i 's|server: https://HAPROXY_IP:6443|server: https://127.0.0.1:6443|' \ /etc/kubernetes/kubelet.conf sudo systemctl restart kubelet
Apply this on every control-plane node after it joins the cluster. Workers must not be redirected — they do not run an API server, so localhost:6443 would fail. Workers continue to use HAProxy, which is the correct path for them.
This redirect is the single most important HA hardening step that the basic kubeadm guide does not need. Without it, a HAProxy failure takes down all three control planes even though each has a healthy API server.
Joining the Second and Third Control Planes
The init output contains a join command for control-plane nodes. It includes three parameters: the token, the CA certificate hash, and the certificate key:
kubeadm join HAPROXY_IP:6443 --token TOKEN \ --discovery-token-ca-cert-hash sha256:HASH \ --control-plane --certificate-key CERT_KEY
Run this command on cp02 and cp03 in sequence — not in parallel. Each join adds an etcd member to the cluster, and etcd needs a stable quorum during membership changes. Joining two nodes simultaneously can split the quorum.
After each join completes, apply the kubelet-to-localhost redirect on that node. Then verify from cp01:
kubectl get nodes
After all three control planes have joined, three nodes should appear with the control-plane role. They will show NotReady until Cilium is deployed.
The Certificate Key Expires in Two Hours
The join token expires after 24 hours, as in the basic setup. But the certificate key expires after only two hours. If you wait longer than two hours between the init and the control-plane join, the join fails with a certificate error.
To regenerate an expired certificate key from the first control plane:
kubeadm init phase upload-certs --upload-certs
This prints a new certificate key. Use it with the existing token and CA hash. If the token has also expired, regenerate both:
kubeadm token create --print-join-command --certificate-key $(kubeadm init phase upload-certs --upload-certs 2&1 | tail -1)
This prints a complete join command with fresh credentials.
Deploying Cilium and Joining Workers
Deploy Cilium from the first control-plane node exactly as in the basic guide:
helm repo add cilium https://helm.cilium.io/ helm repo update helm install cilium cilium/cilium \ --namespace kube-system \ --set ipam.mode=cluster-pool \ --set ipam.operator.clusterPoolIPv4PodCIDRList=10.244.0.0/16
Wait for Cilium to deploy on all control-plane nodes before joining workers. Cilium agents must be running on every node for the cluster to function.
Join the workers using the worker join command from the init output (without --control-plane and without --certificate-key):
kubeadm join HAPROXY_IP:6443 --token TOKEN \ --discovery-token-ca-cert-hash sha256:HASH
After both workers join, label them:
kubectl label node WORKER01 node-role.kubernetes.io/worker="" kubectl label node WORKER02 node-role.kubernetes.io/worker=""
What Survives a HAProxy Failure
Understanding the failure modes is the point of an HA cluster. With the kubelet-to-localhost redirect in place:
| Component | HAProxy down | One CP down | Two CPs down |
|---|---|---|---|
| Control-plane kubelets | Keep running (localhost) | Remaining two run | One runs, no quorum |
| Etcd quorum | Intact (3/3) | Intact (2/3) | Lost (1/3) |
| API availability | Lost for workers/external | Intact via HAProxy | Lost |
| Running pods | Continue running | Continue running | Continue running |
| New scheduling | Works on CPs only | Works | Stopped |
The critical insight: running pods are not affected by control-plane failures. The kubelet on each worker continues to run its pods independently. What stops is scheduling, scaling, and API access. A brief HAProxy outage is invisible to workloads. A prolonged one requires direct API access to a control plane for recovery.
For clusters where control-plane monitoring and incident response are part of the operational baseline, our monitoring and operational support practice covers the alerting and escalation chain. For the orchestration platform itself, our cloud-native orchestration practice handles the deployment and lifecycle.
Validating the HA Cluster
Five checks confirm the cluster is truly HA:
kubectl get nodes
Five nodes, all Ready — three control-plane, two worker.
kubectl get pods -n kube-system -l component=etcd
Three etcd pods, all Running, one per control-plane node.
kubectl get pods -A -l k8s-app=cilium -o wide
Five Cilium agents, one per node.
Deploy a test workload and verify it runs on a worker. Delete it afterward.
The Pod Security Standards article explains what your kubeadm cluster, single or HA, does not enforce by default — and what you need to enable before running production workloads.
What This Article Does Not Cover
This article covers the kubeadm HA topology with an external load balancer. It does not cover DNS configuration with Bind9 — the support material details that step, and /etc/hosts entries work for a lab. It does not cover alternatives to HAProxy (keepalived+VIP, kube-vip, cloud load balancers). It does not cover etcd backup and restore, which is essential for production HA but is a separate operational topic. It does not cover stacked vs external etcd topologies — this guide uses the stacked topology where etcd runs on the control-plane nodes. And it does not cover upgrading an HA cluster, which follows a specific node-by-node sequence starting with the first control plane.
References
Kubernetes Project. Creating Highly Available Clusters with kubeadm. kubernetes.io, 2026.
Kubernetes Project. Options for Highly Available Topology. kubernetes.io, 2026.
HAProxy Technologies. HAProxy Configuration Manual — TCP Mode. haproxy.com, 2026.
EC INTELLIGENCE. Formation Administration Kubernetes — Exercice d’Installation d’un Cluster HA. 2026.