A developer who just wants “a web service with a database” usually gets seven YAML files and a “figure it out.” Kube Resource Orchestrator offers a different path: the platform team describes the resource bundle once, as a graph, and the developer applies a single CRD with three fields. The difference isn’t that someone finally wrote good docs — it’s that composition no longer needs to be copy-pasted by hand.
Table of contents
Open Table of contents
- The problem: seven manifests for one service
- What ResourceGraphDefinition changes
- KRO, Crossplane Compositions, and Helm with conditionals aren’t the same thing
- Practice: a WebApp over a Deployment, Service, and CloudNativePG
- Self-service via Backstage: one software template instead of three YAMLs
- Where KRO doesn’t fit
- How to verify it works
- Bottom line
The problem: seven manifests for one service
A typical “just a web service” in a cluster is never one resource. It’s a Deployment with the right limits and probes, a Service, a ConfigMap with the app config, a separate CloudNativePG Cluster for the database, a Secret with the connection string, sometimes an Ingress and a HorizontalPodAutoscaler on top. A developer who needs a database and an HTTP endpoint gets, at best, a Helm chart with a dozen value fields — at worst, a pile of YAML copied from a neighboring service and hand-tuned.
There are two standard answers to this pain. The first is to write a full Kubernetes operator in Go: kubebuilder or operator-sdk, your own controller, your own reconcile loop, your own tests. That’s heavyweight for an internal platform where such a CRD is needed once or twice, not shipped as a product with a public API. The second is a Helm chart with a mountain of {{ if }} and {{ range }}, which quickly turns into an unreadable template on top of a template: the dependency logic between resources gets smeared across conditionals instead of being stated explicitly.
What ResourceGraphDefinition changes
KRO introduces one new top-level CRD — ResourceGraphDefinition (RGD). In it, the platform engineer describes, once: the new API the developer will get (kind: WebApp, with image, replicas, dbSize fields), which primitive resources it’s made of (Deployment, Service, a ConfigMap, an external CloudNativePG Cluster), and how those resources’ fields reference each other.
The key mechanic is references via CEL expressions written directly into the resource manifest — not a separate DAG. If Service.spec.selector points at ${deployment.spec.template.metadata.labels} and ConfigMap.data.DB_HOST points at ${cluster.status.host}, KRO builds the dependency graph from those expressions itself and applies resources in the right order: whatever the others depend on first, then the dependents — and it waits for cluster.status.host to actually appear in status before creating the ConfigMap. The controller logic you’d otherwise have to hand-write — “wait for Postgres to come up, then create the config” — here simply falls out of the reference graph.
Once the RGD is applied, KRO generates a real CRD (WebApp) and stands up its own controller for it dynamically — no recompiling or redeploying KRO itself. From then on the developer only sees WebApp in the cluster, not what it’s built from.
KRO, Crossplane Compositions, and Helm with conditionals aren’t the same thing
All three approaches solve “give the developer a simple API instead of a resource bundle,” but at different layers. Crossplane Compositions do the same job as RGDs, but for cloud infrastructure outside the cluster — RDS, VPCs, IAM roles — via providers that translate Kubernetes resources into cloud API calls. KRO only works inside the cluster: it can’t create an RDS instance on its own, but it can fold a Crossplane resource into a composition alongside a plain Deployment and Service, as long as Crossplane is already installed as a provider.
Helm with conditionals is the most accessible option, but it doesn’t solve the composition problem — it masks it with a text templating engine: dependencies between resources aren’t declared, they’re implied by hook ordering and wait flags. An RGD stores the dependency graph as data (CEL references), not as line order in a file — that’s exactly what lets KRO derive the apply order automatically, something Helm has no way to do.
| KRO | Crossplane Compositions | Helm + conditionals | |
|---|---|---|---|
| Scope | Resources inside the cluster | Cloud infrastructure + cluster | Anything, but in text |
| How dependencies are described | CEL references in the RGD, graph built automatically | Patches between composition fields | Hook ordering, by hand |
| Needs a Go controller | No — generated dynamically | No, but needs a cloud provider | No |
| What the developer gets | A simple CRD (WebApp) | A simple CRD (XDatabase etc.) | A values.yaml with dozens of fields |
| Where it’s stronger | Self-service over in-cluster resources | Multi-cloud infrastructure provisioning | Fast start with no new dependencies |
Practice: a WebApp over a Deployment, Service, and CloudNativePG
Take that same typical case — a web service with a database on CloudNativePG (covered in a separate post on schema-as-code). The platform team describes the ResourceGraphDefinition once:
apiVersion: kro.run/v1alpha1
kind: ResourceGraphDefinition
metadata:
name: webapp
spec:
schema:
apiVersion: v1alpha1
kind: WebApp
spec:
image: string
replicas: integer | default=2
dbSize: string | default="1Gi"
status:
dbHost: ${cluster.status.host}
ready: ${deployment.status.readyReplicas}
resources:
- id: cluster
template:
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: ${schema.metadata.name}-db
spec:
instances: 1
storage:
size: ${schema.spec.dbSize}
- id: deployment
template:
apiVersion: apps/v1
kind: Deployment
metadata:
name: ${schema.metadata.name}
spec:
replicas: ${schema.spec.replicas}
template:
spec:
containers:
- name: app
image: ${schema.spec.image}
env:
- name: DB_HOST
value: ${cluster.status.host}
- id: service
template:
apiVersion: v1
kind: Service
metadata:
name: ${schema.metadata.name}
spec:
selector: ${deployment.spec.template.metadata.labels}
ports:
- port: 80
After this, the developer never sees Cluster, Deployment, or Service as separate objects — they apply one thing:
apiVersion: v1alpha1
kind: WebApp
metadata:
name: checkout
spec:
image: registry.internal/checkout:1.4.2
replicas: 3
dbSize: "5Gi"
KRO creates the Cluster itself, waits for CloudNativePG to publish status.host, and only then creates the Deployment with the database address already substituted in — because ${cluster.status.host} in the Deployment template is exactly the reference the controller uses to derive ordering.
Self-service via Backstage: one software template instead of three YAMLs
Pairing this with a service catalog makes the loop even shorter: if Backstage’s scaffolder is already set up (as covered in the post on building a catalog from scratch), a software template can generate not a pile of files but a single WebApp manifest and push it to the GitOps repo. The developer fills in a form with three fields — name, image, database size — and gets a working service with a database in the cluster, never seeing a ResourceGraphDefinition and never knowing that WebApp is built from three primitive resources underneath. That’s the actual point of pairing a catalog with KRO: a self-service API, not just a shorter YAML file.
Where KRO doesn’t fit
KRO is a good fit when everything you need to compose lives inside a single cluster or is reachable through an already-installed Crossplane provider. It’s a poor fit when the main job is complex multi-cloud provisioning: standing up VPCs across three AWS regions, peering them, issuing IAM roles, and only then deploying a cluster on top of that. That’s where Crossplane has something KRO lacks out of the box — a mature ecosystem of cloud-specific providers, composition functions for complex field transformation, and managed-resource lifecycle handling that accounts for cloud API state, not just Kubernetes objects. Modeling that kind of provisioning through an RGD is technically possible, but it means reinventing what Crossplane’s providers already handle and have battle-tested. The rule is simple: if you’re composing Kubernetes resources, reach for KRO; if you’re composing cloud infrastructure, reach for Crossplane — and KRO can sit on top of it for the last, in-cluster layer of composition if you want both.
How to verify it works
After kubectl apply -f resourcegraphdefinition.yaml, first check that KRO accepted the graph and generated the CRD: kubectl get resourcegraphdefinition webapp -o jsonpath='{.status.state}' should return Active, not Inactive with an error in .status.conditions. Next, kubectl get crd webapps.kro.run should exist — that’s the dynamically generated API. After applying an instance, kubectl get webapp checkout -o jsonpath='{.status.ready}' should eventually show a number of replicas matching spec.replicas, and kubectl get cluster checkout-db should confirm CloudNativePG actually brought the database up rather than hanging on a missing storage class.
Bottom line
KRO doesn’t replace Crossplane, nor does it replace writing operators where they’re genuinely warranted — it closes the gap between “copy-paste YAML by hand” and “write a Go controller for one internal API.” The cost of entry is learning how CEL references in a ResourceGraphDefinition turn into a dependency graph and an apply order. The payoff is developers getting a WebApp instead of three files, and the platform team getting a self-service API it can wrap in a Backstage software template without opening a Go module for a single entity. The choice between KRO and Crossplane isn’t about trends — it comes down to what you’re actually composing: Kubernetes resources, or cloud infrastructure beyond them.