On February 3, 2026, the Kubernetes project introduced the Node Readiness Controller — a new kubernetes-sigs project that lets operators declare, in YAML, exactly which conditions a node must satisfy before the scheduler is allowed to place a single pod on it. It's a small idea with a blunt premise: a node reporting Ready has, for the entire life of Kubernetes, meant "the kubelet is alive and the container runtime works," never "this node is actually safe to run your workload on." Every platform that cared about the difference has spent years hand-wiring its own answer with ad hoc taints. Now there's a first-class primitive for it.
That gap isn't hypothetical. In Cluster API issue #8357, operators hit a genuine race: the node.cluster.x-k8s.io/uninitialized taint that Cluster API applies to every new node can't be removed until the external cloud controller manager assigns it a providerID — but the CCM itself needs a node it can schedule onto, and cluster creation stalls in the deadlock. In CAPH issue #1207, worker nodes came up fully registered and then failed with system:anonymous cannot create resource "nodes" — authenticated to the cluster, unauthorized to actually join it. Both are proof that "the node joined" and "the node is trustworthy" have never been the same event — and on a Cluster API fleet provisioning its own bare-metal machines, that gap is wider than on any managed cloud. Here's exactly how the Node Readiness Controller works, and where it plugs into a Hetzner-backed CAPH fleet's own bootstrap sequence.
What "Ready" Actually Checks — and What It Never Did
Kubernetes' node Ready condition has always measured one thing: the kubelet is sending heartbeats and the container runtime responds. That's it. It says nothing about whether the CNI plugin has actually programmed the node's network, whether a GPU node's driver has finished loading, or whether a security agent your compliance policy requires has installed itself. A node can flip to Ready while all three are still in progress, and the default scheduler has no way to know the difference — it sees Ready and starts placing pods.
Every platform that has run into this has solved it the same ad hoc way: with a taint. Kubernetes itself already ships one. When a component runs with --cloud-provider=external, the kubelet applies node.cloudprovider.kubernetes.io/uninitialized:NoSchedule at boot and expects an external cloud controller manager to remove it once the node's providerID and network routes are set. Cluster API layers its own version on top — node.cluster.x-k8s.io/uninitialized — for exactly the same reason: give the CCM a signal to hold the node back until infrastructure-level setup finishes. The Kubernetes blog's own account of this pattern calls it, accurately, a chicken-and-egg problem — the CCM needs a node to run on to initialize nodes.
Those two taints solve the cloud-controller-manager slice of the problem. They say nothing about CNI, GPU firmware, or any custom health check a platform operator wants enforced before real traffic lands. Historically, that meant every platform wrote its own controller to watch some condition and manage its own taint by hand — exactly the hand-wiring the Node Readiness Controller is built to replace with one reusable primitive.
The Node Readiness Controller, Concretely
The Node Readiness Controller (NRC) ships one CRD: NodeReadinessRule. A rule names one or more node conditions that must report a required status, and a taint the controller manages automatically based on whether those conditions hold:
apiVersion: readiness.node.x-k8s.io/v1alpha1
kind: NodeReadinessRule
metadata:
name: cni-readiness-rule
spec:
conditions:
- type: "cniplugin.example.net/NetworkReady"
requiredStatus: "True"
taint:
key: "readiness.k8s.io/acme.com/network-unavailable"
effect: "NoSchedule"
value: "pending"
enforcementMode: "Continuous"
nodeSelector:
matchLabels:
node-role.kubernetes.io/worker: ""While cniplugin.example.net/NetworkReady isn't True, the controller keeps the node tainted. The instant it flips, the taint comes off and the node becomes schedulable — no controller of your own to write, no race with a hand-rolled watch loop.
Two things make this more than a wrapper around kubectl taint:
enforcementModedistinguishes one-time setup from ongoing health.bootstrap-onlychecks a condition once during initialization and stops — the right mode for something like a driver install that either succeeds or the node never comes up.Continuouskeeps watching for the life of the node and re-applies the taint if a previously-healthy condition regresses — the right mode for anything that can silently fail later, like a security agent that crashes mid-life.- The controller is decoupled from health checks. It doesn't decide what "ready" means — it just reacts to Node Conditions already on the object, populated by whatever's already reporting them (Node Problem Detector, or the project's own lightweight Readiness Condition Reporter sidecar). A
dryRunModeflag lets an operator see exactly which nodes a new rule would taint before it does anything to live traffic.
This isn't a paper design. DigitalOcean's managed Kubernetes (DOKS) ships three NodeReadinessRule objects by default, on every cluster, automatically — not opt-in:
| Rule | Scope | Gates on |
|---|---|---|
doks-critical | all worker nodes | Cilium agent readiness + control-plane bridge readiness |
amd-gpu-critical | AMD GPU nodes | doks.kubernetes.io/AmdGpuReady: True |
nvidia-gpu-critical | NVIDIA GPU nodes with DCGM | doks.kubernetes.io/NvidiaGpuReady: True |
CNI readiness, GPU driver readiness, and platform-specific control-plane checks, each expressed as one small YAML object instead of a bespoke controller. The project is honest about where it stands — v1alpha1, implementing a proposed KEP (5233/5416) as an out-of-band solution rather than a change to core kubectl/scheduler code. It isn't a Kubernetes v1.36 feature; it's a sigs project any cluster can adopt today regardless of Kubernetes minor version.
Mapping It Onto a CAPH/Hetzner Fleet
Here's what actually happens, in order, when Cluster API Provider Hetzner (CAPH) brings up a new worker node on a self-hosted fleet:
kubeadm joinruns on the new machine and the kubelet registers the node with the control plane.- Cluster API immediately applies
node.cluster.x-k8s.io/uninitialized:NoSchedule— the node exists, but nothing should land on it yet. - The external cloud controller manager (Hetzner's CCM) sets the node's
providerIDand network routes, then removes that taint. - The CNI daemonset (typically Cilium or Flannel on a CAPH cluster) needs to get scheduled onto the node and finish programming its networking.
- Only after step 4 completes is the node actually able to run a pod that needs real pod-to-pod networking — which is every tenant workload on the platform.
The gap is between steps 3 and 4. The CCM's taint removal in step 3 is the only readiness signal Cluster API enforces by default. Nothing in the stack currently stops the scheduler from placing a tenant pod on the node the instant that taint disappears — even though the CNI daemonset might still be pulling its image or finishing its own startup. On a quiet cluster this window is usually short enough that nobody notices. On a fleet mid-scale-up, adding several nodes at once during a traffic spike, it's exactly the moment a tenant pod is most likely to land on a node that can't route its traffic yet — and comes back to life as a mysteriously flaky pod rather than a clean scheduling failure.
A NodeReadinessRule closes precisely that window, using the same CNI-readiness condition CAPH's own CNI daemonset (or a small reporter sidecar) can already expose:
apiVersion: readiness.node.x-k8s.io/v1alpha1
kind: NodeReadinessRule
metadata:
name: caph-cni-readiness
spec:
conditions:
- type: "cilium.io/CiliumIsUp"
requiredStatus: "True"
taint:
key: "readiness.k8s.io/caph/cni-not-ready"
effect: "NoSchedule"
value: "pending"
enforcementMode: "Continuous"
nodeSelector:
matchLabels:
cluster.x-k8s.io/cluster-name: "hetzner-fleet"Continuous mode matters here specifically because CNI agents on bare metal do crash and restart — a rule that only checked once at bootstrap would miss a regression and leave the node schedulable through an outage it can't actually serve. A second rule can gate on something CAPH's cloud counterpart never needs to check at all: a disk-health or firmware condition unique to a Hetzner Robot bare-metal box, reported by a small local check rather than inherited from any cloud API.
Why the Gap Is Wider on Bare Metal Than on a Cloud VM
A Hetzner Cloud VM (HCloudMachine in CAPH's terms) boots a pre-baked image through the Hetzner Cloud API in seconds — the same template every time, the same known-good firmware, the same virtual NIC driver Hetzner controls end to end. A Hetzner Robot dedicated server (HetznerBareMetalHost in CAPH's inventory model) goes through a fundamentally different path: CAPH registers the physical machine as inventory, boots it into rescue mode over PXE, and installs the OS image from there before kubeadm ever runs. That's several more independently-timed steps than a cloud template boot, each one a place a real machine's real hardware can straggle in a way a virtual machine's identical siblings never do — a firmware quirk on one box, a slower NVMe initializing on another, a security agent that needs a kernel module built for that specific host's kernel.
Every one of those steps happens after the node has already registered with the control plane and before any of Cluster API's built-in taints would catch it, because none of them are CCM-related — they're bare-metal-specific, and CCM taints only ever covered the cloud-controller slice of "ready." That's the concrete version of the TODO's framing: a Cluster API fleet on owned bare-metal hardware provisions machines faster than it can actually trust them, and the more heterogeneous the hardware, the more places that gap can hide. A CNI-readiness rule closes the daemonset half of it; a bare-metal-specific hardware-health rule, expressed as the same kind of NodeReadinessRule object, closes the rest — without hand-rolling a bespoke controller for each one.
Adopt Now, or Wait for It to Land Upstream?
The honest caveat: this is v1alpha1, and the KEP it implements hasn't merged into core Kubernetes. The CRD schema, the taint-key convention, even the project name could still change before it graduates. That's a real reason to keep a close eye on the KubeCon EU 2026 maintainer track session before betting production tooling on the exact API shape.
It's not, however, a reason to wait to adopt the pattern. The taint-and-condition mechanism NRC formalizes is exactly what Kubernetes' own cloud-provider taint and Cluster API's uninitialized taint already do today — NRC just makes it declarative and reusable instead of one-off. A self-hosted fleet running Cluster API already has every piece this needs: node conditions to report against, a controller pattern it already trusts, and — unlike a hosted PaaS's tenants, who never see any of this — full control over its own bootstrap sequence to fix. Closing the CNI-readiness gap between steps 3 and 4 above is worth doing with a hand-rolled taint-watcher today if NRC's alpha status gives a team pause; it's worth doing with NRC itself the moment that pause goes away.
This is the kind of fleet-ops maturity a Cluster-API-based platform inherits for free as it matures upstream. Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own, provisioned through Cluster API on Hetzner. Star the repo on GitHub or deploy your first app today.
Sources
- Introducing Node Readiness Controller — Kubernetes Blog, Feb 3, 2026.
- kubernetes-sigs/node-readiness-controller — GitHub.
- Node Readiness Controller docs.
- How to Use the Node Readiness Controller in DOKS — DigitalOcean Docs.
- The Kubernetes Node Readiness Controller: never schedule pods on half-ready nodes — Jorijn Schrijvershof.
- The Cloud Controller Manager Chicken and Egg Problem — Kubernetes Blog, Feb 14, 2025.
- node.cluster.x-k8s.io/uninitialized causes a race condition — cluster-api issue #8357.
- Cluster startup fails after control plane — cluster-api-provider-hetzner issue #1207.



