Skip to content
shamimnael
Automation at scale

The plan and the loop

Crossplane and Terraform both provision infrastructure declaratively. The difference that decides everything else is where desired state lives and who closes the loop.

Almost every Crossplane versus Terraform comparison argues about the wrong things. YAML against HCL. Kubernetes against a binary. Whether you enjoy writing compositions. None of that survives contact with a system you still have to operate two years later.

There is one real difference, and it is architectural. Terraform holds desired state in files and closes the loop when something tells it to. Crossplane holds desired state in an API server and closes the loop continuously, whether you are paying attention or not.

Every trade-off below falls out of that single fact. Drift behaviour, blast radius, who can be handed the interface, what happens at three in the morning, what you are now on call for. If you understand the loop, you can derive the rest yourself.

One thing to declare before we start, because it should change how you weigh what follows. Terraform I run, across many separate states, applied through CI. Crossplane I have studied closely rather than operated. So read the Terraform judgments as lived and the Crossplane ones as reasoned from its design and its documentation. Where that distinction matters below, I say so.

The difference everything else follows from

Terraform’s model is plan and apply. You describe what you want, the tool reads what exists, computes a diff, and shows it to you. Someone approves. The diff is executed. Then Terraform stops running, and it has no further opinion about your infrastructure until the next time you invoke it.

Crossplane’s model is a control loop. You submit an object to the Kubernetes API server, and that object is now the desired state. A controller watches it, compares it against the world, and acts to close the gap. Then it does that again. And again, forever, until you delete the object.

Two control models

The state file gets most of the attention in these arguments, and it is the wrong thing to focus on. Terraform’s state file and Crossplane’s etcd are both caches of what the tool believes it built. They differ in refresh policy, not in kind. What actually differs is who is responsible for noticing that the cache is wrong. In Terraform that is you, on a schedule you chose. In Crossplane that is a controller, on a schedule it enforces.

That distinction sounds academic until it isn’t.

What Terraform is in 2026

Worth stating plainly, because a lot of writing on this still describes the 2023 version of the landscape.

The licence changed to BUSL 1.1 in 2023, OpenTofu forked, and IBM completed its acquisition of HashiCorp on 27 February 2025. Terraform’s roadmap is now set inside a large enterprise vendor. In practice, for internal use, less changed than the noise suggested.

Terraform 1.16 is current as of August 2026, with 1.15 also supported. Anyone still on 1.14 should note it reached end of life on 26 August 2026.

Two additions from 1.14 are more interesting than they first appear. List resources introduced .tfquery.hcl files and a terraform query command, so you can interrogate existing infrastructure and generate importable configuration from it. That is Terraform finally taking brownfield seriously. The actions block, with the -invoke flag, lets you attach imperative side effects to resource lifecycles: invoke a Lambda, invalidate a cache, run a playbook. Stacks also moved from beta to general availability, with the standalone terraform-stacks-cli folded into the main binary.

OpenTofu sits at 1.12.x under MPL 2.0, became a CNCF Sandbox project on 23 April 2025, and has shipped features the open Terraform binary does not have: state encryption, for_each on providers, early variable evaluation, and -exclude. Both consume the same provider binaries, so the ecosystem did not actually split where it would have hurt.

Note what the actions block means. Terraform spent a decade insisting that everything be declarative, met the reality that some operations simply are not, and added a controlled way to run them. Hold that thought.

What Crossplane is in 2026

Crossplane 2.0 went GA on 12 August 2025, and the project graduated in the CNCF on 6 November 2025. Current is v2.4, on a quarterly cadence. The v1 line is ending: v1.20 reaches EOL when v2.5 ships in November 2026.

If your mental model of Crossplane is from v1, most of it is now wrong.

Composite resources are namespaced by default. So are managed resources. XRDs carry a scope field, defaulting to Namespaced, and you opt into Cluster when you need it. Cluster-scoped managed resources still work but are explicitly legacy.

Claims are gone. In v1 you created a namespaced Claim which created a cluster-scoped XR which composed cluster-scoped managed resources. That existed to give namespace-scoped teams a namespace-scoped handle on cluster-scoped things. Once XRs themselves became namespaced, the whole indirection stopped earning its place.

Patch and transform is gone. It was deprecated in v1.17 and removed in v2. Compositions are now always a pipeline of composition functions. Functions are programs, in a language you choose, that receive the observed state and return the desired state.

Managed resource definitions let you activate only the managed resources you actually use, instead of installing every CRD a provider ships. Anyone who has watched a cloud provider install hundreds of CRDs into a cluster will recognise the problem being solved.

And XRs can now compose any Kubernetes resource, not only managed resources. A composition can produce an RDS instance, a Deployment, an ExternalSecret and a NetworkPolicy as one unit. This is the change that most invalidates older comparisons, several of which still claim Crossplane cannot compose native Kubernetes objects.

The Crossplane v2 object model

Note what happened here too. Crossplane started with a declarative templating DSL, found it could not express what people needed, and replaced it with real code. Terraform arrived at the same place from the opposite direction. Both projects independently concluded that pure declarative configuration is not sufficient on its own, which should tell you something about the shape of the problem rather than about either tool.

Drift, where the models genuinely part

This is the axis people mean when they say Crossplane is “self-healing”, and it is worth being precise about.

Under Terraform, an out-of-band change survives until the next run. If your pipeline plans nightly, drift can live for a day. If it plans on merge, drift can live indefinitely, because nobody merged. Then a person has to read the plan and approve. The window is a function of your pipeline cadence and who is available, not of the tool.

Under Crossplane, the controller is already watching. Resources are reconciled through Kubernetes watches where possible and polling where not, with a default poll interval of one minute. It can be tuned globally with --poll-interval, or per resource with the crossplane.io/poll-interval annotation, which takes a Go duration such as 30m or 24h. --max-reconcile-rate bounds how hard it tries, defaulting to ten per second. Setting crossplane.io/reconcile-requested-at forces an immediate pass.

Drift windows

Continuous correction is not free, and it is not always what you want. Crossplane gives you the dial. managementPolicies set to Observe reads the external resource and reports status without correcting anything, which is how you adopt existing infrastructure without letting the controller touch it. Fields under forProvider are enforced, so external changes get reverted. Fields under initProvider are set once and then left alone, so external changes are tolerated. That distinction is one of the more useful things in the API and one of the least discussed.

The honest framing is not that one tool detects drift and the other does not. Terraform detects drift perfectly well, whenever you ask it. The difference is that Crossplane’s answer to “how quickly is this corrected” is a number you configure, and Terraform’s answer is a question about your organisation.

Which one you want depends on whether unattended correction is a feature or a hazard. For a database parameter group, it is a feature. For a resource that an on-call engineer deliberately changed at 03:00 to stop an outage, a controller that reverts it sixty seconds later is an active participant in your incident.

Blast radius, and what a failure costs

Terraform’s unit of blast radius is the state file. Everything in one state is planned together, locked together and applied together. When a state file gets big enough, plans get slow, locks get contended, and the failure of one resource stalls a change to something unrelated.

This is why state splitting, not module design, is the central decision in a large Terraform estate. The rule I have settled on is unglamorous: a state is a blast radius, so draw the boundary where you would want a failure to stop. In practice that means splitting along the lines the infrastructure already fails along rather than along the org chart, and accepting more states than feels tidy. Each one plans and applies on its own, so a broken change is contained to the thing it broke.

The related habit worth stealing is pushing the parts that change most often out of HCL entirely, into structured data that a small module consumes. The review then becomes a data diff rather than a Terraform diff, and someone can make a routine change without reading HCL at all. That is a self-service interface built the hard way, which is worth holding in mind for the next section.

Crossplane inverts this. Every managed resource has its own controller and reconciles independently. One failing resource does not block its siblings. It sits there, condition Ready: False, retrying, while everything else proceeds. For a large estate this is genuinely better, and it is the strongest technical argument in Crossplane’s favour.

But the blast radius did not disappear. It moved, and it got sharper.

Crossplane’s unit of blast radius is the Composition. Change one, and every composite resource using it converges on the new definition, continuously, without a plan step and without anyone approving a diff. A Composition used by two hundred XRs is a change to two hundred pieces of infrastructure, executed by a loop that does not wait for you.

There is no terraform plan for that. crossplane render will render a composition locally against an example XR, which is real and worth using in CI, but rendering one example is not the same as diffing a change against everything currently running. Coming from an estate where every apply is preceded by a plan somebody reads, this is the property I would want to prove out before committing to Crossplane, and the one I would ask hardest about when someone tells me they have adopted it.

The mitigations are the ordinary ones: version your XRDs, roll Compositions through environments, and use crossplane.io/paused when you need the loop to stop. They work. They are also a discipline you have to impose, rather than a gate the tool imposes on you.

The interface question

Here is the framing I find most useful, and it has nothing to do with either tool’s feature list.

A Terraform module is a library. To consume it you must run Terraform: have the binary, hold credentials, hold state, understand the workspace layout, know what plan output means. You can wrap that in a pipeline or a portal, and plenty of teams do, but you are building that interface yourself and then operating it. The data-driven modules described above are exactly that: a narrow, hand-built API over a library, which works well and which nobody but its authors maintains.

A Crossplane XRD is an API. To consume it you submit an object and stop. The consumer never installs anything, never holds cloud credentials, never sees the resources underneath, and never learns what a composition is. They get an object with a status, in a namespace, checked by RBAC they already have.

If your consumers are the same engineers who write the infrastructure code, this distinction is worth nothing, and the Crossplane machinery is cost with no return. If your consumers are twenty product teams who want a database without learning your cloud, this distinction is the entire point, and it is the reason Crossplane exists.

Notice that this is a question about your organisation, not about your infrastructure.

The bootstrap problem nobody demos

Crossplane runs on Kubernetes. Something has to create that cluster, its node groups, its IAM roles, its networking, and the credentials Crossplane will use. That something is almost never Crossplane.

So the honest picture in most places running Crossplane at any scale is two tools: Terraform or OpenTofu builds the substrate and the management cluster, Crossplane runs on top and serves everything above it. This is not a failure of either tool. Bootstrapping a control plane from inside itself is a genuinely hard problem, and the pragmatic answer has been stable for years.

It does mean the “replace Terraform with Crossplane” framing is mostly wrong. For most teams the realistic question is which layer each one owns, and where the boundary sits.

The second half of this is subtler. Once Crossplane owns provisioning, your provisioning has an uptime requirement. The cluster is now production. Its upgrades, its etcd, its certificate rotation, its provider pods and their memory footprint are all things someone is on call for. Terraform between runs consumes nothing and can fail at nothing. That asymmetry is real and it is routinely skipped in comparisons, because it does not show up in a demo and does show up in a year.

A note on how the providers actually work

Worth knowing, because it changes the licensing and performance story.

Most broad cloud coverage in Crossplane comes from Upjet, which generates providers from existing Terraform providers. Early versions shelled out to the Terraform CLI, which was never designed for continuous reconciliation and cost accordingly. Modern Upjet does not: it consumes the Terraform provider’s Go schema and calls its CRUD functions directly, embedding providers as Go plugins rather than forking a process per resource. The Terraform CLI is out of the package entirely, which also sidesteps the BUSL question.

There is also provider-terraform, which manages a Terraform Workspace as a managed resource, running your existing HCL under Crossplane’s loop. It is a reasonable migration bridge. It is a poor destination, because the unit of reconciliation becomes an opaque workspace rather than a typed resource, and you have given up most of what you came for while keeping both tools’ operational burden.

Where kro fits

Worth a paragraph, because it is now part of this decision and was not two years ago.

kro came out of a joint effort by AWS, Google Cloud and Azure, and is now a Kubernetes SIG Cloud Provider subproject. You write a ResourceGraphDefinition, using CEL for logic, and get a new first-class API that users consume with kubectl. No controller in Go. It composes any Kubernetes object, including the cloud-provider CRDs from ACK, ASO and Config Connector, and AWS offers it managed on EKS.

If you already run ACK or Config Connector and want a clean developer-facing API over them, kro is the more direct route. If you need a mature provider ecosystem with continuous reconciliation of cloud resources, Crossplane is further along, and the differentiator that older comparisons cite, that Crossplane cannot compose native Kubernetes objects, stopped being true in v2.

The framework

Two questions decide this. Everything else is detail.

Who consumes the interface? Engineers who will run the tool themselves, or teams who submit intent and never touch it.

How often must the desired state be re-asserted? Only when something changes, or continuously, without waiting for a human.

The decision axes

The bottom-left quadrant is where most infrastructure lives, and Terraform or OpenTofu is the right answer there. Infrastructure as a versioned artifact, reviewed as a diff, applied by the people who wrote it. Nothing about Crossplane improves this case, and quite a lot about it makes the case worse.

The top-right quadrant is what Crossplane was built for. A platform team offering an API, consumed by teams who should not have to understand what is underneath, with a loop keeping the result true afterwards. If you are genuinely here, Crossplane is not competing with Terraform. It is competing with the internal platform you would otherwise write and maintain yourself.

The other two quadrants are where the expensive mistakes happen. Reconciliation without an abstraction gives you a cluster to operate and no platform API to show for it. A self-service layer over Terraform is entirely legitimate, and many good platforms are exactly that, but be clear that you are choosing to build and own that interface.

A useful test: if you cannot name the teams who will consume your XRDs, and what they will stop having to know, you are not in the top-right quadrant yet.

Things I would not do

Adopt Crossplane to avoid learning HCL. You will trade a language you found awkward for a distributed control plane you now operate. That is not a good trade.

Point both tools at the same resource. Terraform will plan a change to something a controller is actively enforcing. The result is a fight, and the controller has more stamina than your pipeline. Use managementPolicies: Observe and deletionPolicy: Orphan for anything Crossplane should watch but not own.

Wrap everything in provider-terraform and call it a migration. It is a bridge for modules you are not ready to rewrite, not a strategy.

Nest compositions deeply because it is elegant. Debugging a five-level XR tree at three in the morning is where composition stops feeling elegant. crossplane trace exists to help with exactly this, and it is still five levels.

Skip the plan discipline because Crossplane has no plan step. The absence of a gate is not the absence of a need for one.

What this comes down to

Terraform treats infrastructure as an artifact you build. Crossplane treats it as a state you maintain. Both are legitimate, and the second is more expensive, because a loop that runs forever is a thing you operate forever.

The question is not which tool is better. It is whether you are building infrastructure, or building a platform that other people build on. If it is the first, the plan is enough. If it is the second, you were going to need a loop anyway, and the only real choice is whether you write it yourself.