Writing a Kubernetes operator used to be the price of admission for building a platform on Kubernetes. If your PaaS needed a TenantApp resource that expanded into a Deployment, a Service, and an HTTPRoute, somebody wrote a Go controller, wired up kubebuilder scaffolding, and signed up to maintain that binary forever. In the CNCF's Q1 2026 Technology Radar, built with SlashData from a survey of more than 400 developers, the Kube Resource Orchestrator — kro — joined Helm and Backstage in the "Adopt" ring for application delivery. The verdict worth stating up front: kro lets a control plane replace hand-written expansion glue with a single YAML file, but it is expansion, not reconciliation — and on a Cluster-API fleet that already owns dozens of CRDs, the honest accounting has four columns, not one.
| Hand-written Go operator | kro ResourceGraphDefinition | Crossplane Composition | Helm chart | |
|---|---|---|---|---|
| Authoring cost | Highest: scaffolding, reconcile loop, tests, release pipeline | Low: one RGD YAML, CEL wiring | Medium: XRD + Composition, function pipeline to learn | Lowest: templates anyone can read |
| Expressiveness ceiling | Unlimited: arbitrary Go | Bounded: CEL expressions only, no polling or retries | High: patch-and-transform + functions | Bounded: string templating, no types |
| Maturity | As mature as your team | Pre-1.0 (kro.run/v1alpha1), survey-rated "Adopt" | Mature, production-proven | Decades of production use |
| Stack-interaction cost | You own every bug | Two condition layers to chase, kro itself to operate | A second control plane to run | Render-time only, no runtime seam |
Bottom line: use kro for the static-expansion cases — turning one tenant-facing resource into N Kubernetes objects — and keep a real controller for anything stateful: polling DNS for custom domains, enforcing quotas, applying backpressure to a build queue. The rest of this post earns that sentence.
What kro actually does: one custom resource in, a graph of objects out
kro started at AWS in November 2024 as an open-source experiment, became a joint AWS–Azure–Google Cloud collaboration in early 2025, and now lives at kubernetes-sigs/kro as a subproject of SIG Cloud Provider. The whole idea fits in one object, the ResourceGraphDefinition (RGD). An RGD declares three things: a schema (the OpenAPI shape of the new custom resource your tenants will create), resources (the Kubernetes objects each instance should expand into, with fields wired together), and status (which fields from the children get surfaced back up). When you apply an RGD, kro validates it, dynamically creates and registers a real CRD with the API server, and deploys a dedicated lightweight controller for that one definition — one small reconciler per composed API, not one binary per platform team.
The wiring language is CEL, the same expression language behind CRD validation rules, so references are type-checked rather than string-interpolated. A PaaS-typical example makes it concrete: a TenantApp that a tenant creates once, which the platform expands into a Deployment, a Service, and an HTTPRoute:
apiVersion: kro.run/v1alpha1
kind: ResourceGraphDefinition
metadata:
name: tenantapps.platform.example.com
spec:
schema:
apiVersion: v1alpha1
kind: TenantApp
spec:
image: string
replicas: integer | default=2
hostname: string
resources:
- id: deploy
template:
apiVersion: apps/v1
kind: Deployment
spec:
replicas: ${schema.spec.replicas}
template:
spec:
containers:
- name: app
image: ${schema.spec.image}
- id: route
template:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
spec:
hostnames: [${schema.spec.hostname}]
rules:
- backendRefs:
- name: ${deploy.spec.template.spec.containers[0].name}Note what the sketch shows: ${} references flow downward from the tenant's spec and sideways between resources (route reads the container name out of deploy), kro topologically orders creation, and status blocks (omitted here for brevity) project child fields like available replicas back onto the TenantApp. Conditional resources use includeWhen expressions. When something fails, instances report a Ready condition with sub-conditions such as GraphResolved pointing at the broken CEL or template — which matters later, when we count the cost of debugging across layers.
What "Adopt" actually means — and what it doesn't
The rating in the title needs scoping, because "Adopt" sounds like a conformance stamp and it isn't one. The CNCF Technology Radar Report for Q1 2026, produced with SlashData, surveyed more than 400 professional developers about platform-engineering tooling across workflow orchestration, application delivery, and security. In application delivery, three projects landed in the Adopt ring: Helm, Backstage, and kro. Adopt means surveyed practitioners consider the tool mature, useful, and ready for broad use — it is a maturity signal from the field, not an API-stability guarantee.
That distinction matters because kro itself is still pre-1.0: the API group is kro.run/v1alpha1, the project README warns of an experimental surface, and the community (around 2,400 GitHub stars as of late 2025) is still filing the kind of issues young expression-driven projects file — conditional references across mutually exclusive resources, CEL function gaps, error-message polish. Adopt says "teams are betting on this pattern." It does not say "this API will not change under you." Any platform adopting kro today should read the rating as permission to prototype in production-adjacent places, with the v1alpha1 churn risk priced into the decision — which is exactly what the next two sections do.
What it buys a control plane that already speaks Cluster API
A Cluster-API fleet is already a CRD-heavy stack: Cluster, Machine, MachineDeployment, KubeadmControlPlane, plus the infrastructure provider's machines and the platform's own tenant resources. Every new tenant-facing abstraction historically meant another controller in that crowd. kro's concrete saving is deleting the most boring member of the crowd first.
Name the first migration candidate: the tenant app-bundle expansion controller — the loop whose entire job is "watch TenantApp, ensure the Deployment, Service, and HTTPRoute exist with the right fields, update status." Before kro, that is kubebuilder scaffolding, a reconcile function, RBAC markers, envtest suites, and a release pipeline — easily several hundred lines before any business logic, all to express what the RGD sketch above says in about forty. After kro, the expansion is the YAML, and the running code is kro's generic per-RGD controller, maintained upstream instead of by your team. The glue that stays hand-written is everything with a stateful opinion: the controller that polls DNS until a custom domain verifies, the one that sums resource usage against a quota, the one that throttles the build queue under backpressure. Those were never expansion problems; kro doesn't pretend they are.
The comparison with the neighbors clarifies the purchase. Helm renders manifests outside the cluster with string templating — cheap, but no types, no runtime, no status. Crossplane's XRD-plus-Composition machinery is the heavyweight alternative: more expressive, with a function pipeline for real logic, but aimed at provisioning infrastructure across clouds and carrying the weight of a second control plane to operate. kro sits between: lighter and Kubernetes-native, composing any operator's CRDs (including the ACK, KCC, or Crossplane-provider CRDs that map cloud APIs into the cluster) without leaving the API server's own type system. For a PaaS whose problem is "too many small expansion controllers," that middle slot is the whole appeal.
The real cost: one more layer on an already layered stack
Here is the TODO's hardest case, answered directly. Adding a composition layer atop a fleet that already reconciles machines, control planes, and tenant workloads introduces four costs, and a team should be able to recite them before adopting.
First, the CEL ceiling. CEL wires fields; it does not poll, retry, sleep, or branch on external state. Anything needing "check DNS every minute until the TXT record appears" or "fail this rollout back after three crashes" stays in Go. The community's open feature requests — conditional status references across mutually exclusive resources, extra CEL functions — mark exactly where the ceiling currently sits.
Second, two condition layers to chase. Today a broken tenant deploy means reading one controller's logs. With kro, the failure surface is kro's Ready/GraphResolved conditions on the composed instance plus the underlying CAPI or workload conditions on the children. The ownership boundary is clean — kro expands tenant resources, Cluster API owns machines, and they never fight over the same objects — but debugging crosses the seam, and on-call runbooks have to learn both dialects.
Third, upgrade coupling. kro tracks the Kubernetes API surface it composes; every cluster upgrade now has two compatibility questions instead of one (does CAPI support this version, does kro's expression engine and its generated CRDs). At v1alpha1, that question can have breaking-change answers.
Fourth, kro itself is a control-plane dependency. Its controllers need HA placement, resource requests, monitoring, and an upgrade story on the management cluster — the same operational tax as any controller, just amortized across every RGD instead of paid per operator. The saving is real but it is consolidation, not elimination: N bespoke binaries become one shared binary plus N YAML files.
The fits/doesn't-fit list falls out directly. kro fits: tenant app bundles, environment scaffolding (namespace plus quotas plus network policy from one resource), and composing cloud-provider CRDs into platform-level abstractions. kro doesn't fit: provisioning flows with wait-and-poll steps, enforcement logic with arithmetic over live state, or anything where the reconcile loop's backoff behavior is the feature.
The decision rule
Compose what is static; write controllers for what is stateful. If the resource's lifecycle can be fully described as "these objects, with these fields, in this order," it belongs in an RGD — and after the Q1 2026 radar, that choice has field validation behind it. If the lifecycle contains waiting, counting, or judging, keep the Go controller and spend the saved operator budget making that one excellent. A PaaS control plane doesn't stop hand-writing operators all at once; it retires them one boring expansion loop at a time, starting with the app bundle.
Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own. Star the repo on GitHub or deploy your first app today.
Sources
- Resource Composition with kro — Amazon EKS docs
- Introducing Kube Resource Orchestrator — Google Cloud Blog
- Introducing kro: Kube Resource Orchestrator — AWS Open Source Blog
- kro: From Experiment to Community Project — AWS Open Source Blog
- The CNCF Technology Radar Report (Q1 2026)
- CNCF and SlashData: platform engineering tools maturing (Adopt: Helm, Backstage, kro)
kubernetes-sigs/krodesign doc: composition vs providers vs GitOps



