We get asked about Crossplane in almost every platform assessment we run. The question is usually framed as a gap: you do GitOps, you run operators, so why is your infrastructure still described in Terraform modules instead of Kubernetes resources?

The short answer is that we keep the number of layers as low as the problem allows. For the work our clients actually bring us, a versioned module library plus a small set of purpose-built operators delivers the same outcome as a universal control plane, with fewer moving parts to build, test and carry. This is a position on trade-offs, and the trade-offs go both ways. There are teams for whom Crossplane is clearly the right call, and we name them below.

What Crossplane actually is, stated fairly

Any critique that starts from a caricature is worthless, so start with what the project does well.

Continuous reconciliation against plan and apply

Terraform and OpenTofu run a loop that you trigger. You open a merge request, CI computes a plan against real state, someone reads the diff, and apply executes it once. Between two runs, nothing watches the cloud. If somebody edits a security group in the console at 02:00, the change lives there until the next plan surfaces it.

Crossplane inverts that. Your infrastructure is expressed as Kubernetes resources, and a controller reconciles them on an interval for as long as they exist. The same security group edit gets reverted without anyone opening a terminal. That is a real capability, and Terraform has no equivalent to it.


Figure 1. Both models end at the same cloud API. The difference is where the loop lives and who operates it.

Managed resources, providers and composition

A provider brings managed resources, one Kubernetes kind per cloud resource. On top of that, a CompositeResourceDefinition declares an API of your own, and a Composition maps that API onto the managed resources behind it. Composition logic runs in functions, written as YAML patches, or in KCL, CUE, Go or Python.

The v2 line simplified the model in ways worth acknowledging. Claims were removed, composite resources and managed resources are namespaced by default, and composite resources can now compose any Kubernetes resource rather than only managed ones. Managed resource definitions also let you activate only the resource kinds you use, which cuts the CRD count a provider installs. The upgrade guide keeps v1 style resources working under a legacy scope, though teams migrating live claims to namespaced composite resources are still writing their own scripts for it.

The genuine upside

Two things are hard to get any other way. Drift correction happens without a scheduled pipeline and without a human in the path. And a Composition gives application teams a small, opinionated API that hides the twelve fields they should never touch, requested with kubectl and delivered by the same controller loop that delivers everything else in the cluster.

Neither of these is marketing. They are the reasons the project exists, and if you need them, the rest of this article is a description of the price.

Our principle: keep it simple

Why we reach for a purpose-built operator first

We avoid Crossplane for the same reason we avoid most universal layers. The needs we meet in practice are specific, and something built for that one need usually handles it better than a general abstraction handles all needs at once.

A Postgres cluster does not want a generic resource model. It wants failover ordering, replication slots, WAL archiving and a safe path across major versions. A DNS record does not want a control plane. It wants to follow the Ingress that owns it. Those two problems have almost nothing in common, and a layer that treats them the same is a layer that solves neither of them deeply.

One tool that does one thing well

The universal control plane promises one interface for everything. In practice you get one interface and then a per-resource set of exceptions, because the depth each resource needs is different. The exceptions are where the work goes, and they are invisible in every diagram of the model.

What we run instead, and why it wins

Postgres: CNPG

For Postgres we run CloudNativePG. It installs separately, and that separate install is exactly the point: what it gives us has no equivalent in a generic provisioning layer. Automated failover promotes the most current replica, coordinated by a per-cluster lease that serializes promotion and prevents split brain. Failed replicas are recreated. Backups cover scheduled and on-demand runs with full and point-in-time recovery, with WAL archiving to object storage and the option to back up from a standby to keep load off the primary.

Two features make the difference concrete. Replica probes can be gated on replication lag, so a replica only becomes ready, and only becomes promotable, once it has caught up. And since 1.26 a major version upgrade is declarative: you change the operand image to a higher major, the operator runs pg_upgrade, and if it fails you revert the image and the operator cleans up the failed job and restarts the cluster on the old version.

Crossplane has no mechanism for any of that, and this is not a gap in its implementation. Nothing in the managed resource model expresses promotion ordering or a lag-gated readiness gate. provider-sql manages databases, roles, grants, default privileges and extensions on a server that already exists, which is useful and is a different job. The Postgres itself still comes from CNPG or from a managed cloud service.


Figure 2. Crossplane does not run Postgres. It puts an API you wrote, and now maintain, in front of something that does.

The comparison below is not about which project is better. It is about which lifecycle operations exist as first-class concepts in each model.

Lifecycle operation

CNPG

Crossplane

Automated failover

Native, lease-coordinated promotion of the most current replica

No mechanism; delegated to whatever runs the database

Point-in-time recovery

WAL archiving plus full and PITR restore, including from volume snapshots

No mechanism; delegated

Replica readiness on lag

Startup and readiness probes gated on replication lag

No mechanism; readiness is generic resource readiness

Major version upgrade

Declarative in-place upgrade via pg_upgrade, with automatic rollback on failure

No mechanism; a managed service handles it, or you do

Connection routing

Operator-managed services for primary and replicas

No mechanism

Databases, roles, grants

Declarative Database resource

provider-sql, against a server that already exists

Provisioning a cloud DB instance

Out of scope

First class, via the cloud provider

For context on maturity: CNPG was accepted into the CNCF at Sandbox level in January 2025, the intellectual property sits with the community under Apache 2.0, and the project is explicit in its scope that it supports vanilla Kubernetes and vanilla PostgreSQL and nothing else. That narrowness is the feature.

DNS: external-dns

external-dns watches Services and Ingresses and writes the matching records to your DNS provider. It has one job. It has no API of yours to maintain, no schema to version, no composition logic to test. When we hand it over, the runbook is short enough that the receiving team reads it once.

The same record set expressed through a control plane means a managed resource per record, a Composition to generate them from something meaningful, and a mapping from application intent to that Composition. The output is identical. The surface is not.

How we handle drift without a control plane correcting it

This is the honest cost of our position, so we state it plainly. We do not get continuous correction of cloud resources. What we do instead is narrow the window in which drift can appear and make it visible when it does.

  • Console and portal write access is removed in the environments that matter, so the main source of drift is closed rather than repaired after the fact.

  • Plans run on a schedule against every environment, not only on merge, and a non-empty plan is a signal someone reads.

  • Anything that genuinely needs a reconcile loop, and workloads mostly do, already lives in the cluster where Argo CD reconciles it continuously.

The result is that drift is detected in minutes to hours rather than corrected in seconds. For a database subnet group, that difference has never cost one of our clients anything. If your risk model says otherwise, that is a legitimate reason to choose the other model.

The complexity Crossplane adds back

Everything becomes a CRD

Modeling infrastructure as Crossplane resources means generating and maintaining custom resource definitions, and then maintaining the tooling around them. Composition functions are real programs with real dependencies. The Go SDK ships unit tests with the template, and there is a render command for the inner loop, which helps. What stays hard is testing a Composition in isolation, because meaningful behaviour only appears when a live controller reconciles a live resource against a live provider.

Every abstraction is more surface to build, test and carry

A Composition is a program that produces infrastructure. It has inputs, branches, defaults and failure modes. It needs review, versioning and a deprecation story, because the moment an application team depends on your API you cannot change it freely. That is normal API product work, and it is work that did not exist before you introduced the abstraction.

What you own

Module library plus operators

Crossplane control plane

Source of truth

Module calls and values in Git

Manifests in Git, reconciled state in the cluster

Artifacts you write

Modules, variable files, workload manifests

XRDs, Compositions, functions, provider configs, plus the manifests

Test loop

plan in CI against a real account

Render locally, then a live controller against a live provider

Failure blast radius

One pipeline run, one stack

The control plane, shared by everything it reconciles

Upgrade burden

Provider and module versions

Crossplane, providers, functions, plus your own API versions

Who can debug it

Anyone who reads a plan

Anyone who reads controller logs and resource conditions

Push everything through and you get bloatware

Every individual decision here is defensible. Adding an XRD for databases is fine. Adding one for buckets is fine. Twenty of them later you have a private cloud API written by three people, and the layer meant to reduce cognitive load is the thing new engineers spend their first month learning. The cost is not any one abstraction, it is the accumulation.

The control plane is production infrastructure

Set the simplicity argument aside for a moment. Crossplane needs a Kubernetes cluster to run in, and that cluster is now production. You patch it, upgrade it, back up its state, secure it, and hold cloud credentials in it that can create and delete real resources. If it is down, nothing gets provisioned. If it is compromised, the blast radius is every account it can reach.

This is a secondary point and we present it as one. It supports the main argument rather than carrying it.

The honest counter-case: when Crossplane is right

The position above depends on a set of conditions. When those conditions flip, so does the answer.


Figure 3. Three conditions we check before recommending either model.

A team that already thinks in CRDs

If controllers, conditions and reconcile loops are how your engineers already reason, Crossplane is not a new mental model, it is the existing one applied to a wider set of resources. The learning cost we describe above is close to zero for that team, and most of our argument loses its force.

Infrastructure lifecycle tied to application lifecycle

This is the strongest case for the model. Delete the application resource and its bucket, its queue and its database user go with it, because ownership is expressed in the Kubernetes object graph rather than in a pipeline someone has to remember to run. Orphaned cloud resources are a real and recurring cost, and this solves them structurally. Crossplane v2 leaned into exactly this by making application composition a first-class use case.

A platform team large enough to own it

The control plane and the APIs on top of it are a product. They need an owner, a support path, a versioning policy and someone on call. A team of that size and shape exists in plenty of organisations. In those organisations the operational cost we describe is amortised across dozens of consuming teams, and the arithmetic changes completely.

What we push back on is adopting the model in advance of those conditions, in the hope that they will arrive. They sometimes do. The abstraction always arrives first.

Closing: pick the model, not the hype

Both models get a cloud resource into the state you declared. Choose based on where the reconcile loop should live and how much of it you want to operate, and revisit that choice as the team grows. The wrong version of this decision is picking a control plane because it appears in every platform engineering talk, then discovering that the four people maintaining it are the only people who can change anything.

Our default is the simpler stack, and we are explicit that it is a default rather than a rule. When a client meets the three conditions above, we say so.

We run platform assessments that end in a written recommendation on exactly this kind of decision, including the case against our own default. If you are weighing a control plane against a module library right now, talk to an engineer and we will work through your constraints rather than ours.

Oleksandr Simonov

Founder and CEO @ Amoniac OÜ

Working with Linux since 1998. I've seen every layer of the stack break in every possible way — so when you describe your problem, I usually know where to look before the call even starts.

Oleksandr Simonov

Founder and CEO @ Amoniac OÜ

Working with Linux since 1998. I've seen every layer of the stack break in every possible way — so when you describe your problem, I usually know where to look before the call even starts.

Oleksandr Simonov

Founder and CEO @ Amoniac OÜ

Working with Linux since 1998. I've seen every layer of the stack break in every possible way — so when you describe your problem, I usually know where to look before the call even starts.

SHARE ON SOCIAL MEDIA

Start with a focused platform assessment

Our first engagement is a paid technical assessment. We map your delivery workflow, infrastructure gaps, platform debt, and implementation priorities before anything is built.

This is a paid first engagement, designed to produce a roadmap your team can act on.