Infrastructure as Code
Kubernetes as Your Infrastructure Control Plane with Crossplane
Use Crossplane to make Kubernetes your infrastructure control plane — a self-service platform API for cloud resources, reconciled continuously.
Kubernetes already runs a control loop for your workloads. Crossplane extends that same idea to the infrastructure underneath them: a platform team publishes a Kubernetes API for things like databases, buckets, or internal platform services, and a controller reconciles the real cloud resources behind that API continuously.
The interesting question is less about YAML syntax and more about where you want the control plane to live. If you provision from a laptop or a CI pipeline, Terraform’s explicit plan-and-apply is still the default for good reason. But if you want application teams to request infrastructure through the same Kubernetes API they already use for workloads — and have it stay reconciled — that is what Crossplane is for. It is strongest when the thing you offer should feel like a platform product instead of a ticket queue.
One thing to pin down first: this is written for Crossplane v2, the current line. v2 was a big break from v1 — composite and managed resources are namespaced by default, and Compositions are built from functions rather than an inline patch-and-transform block — so a few of the patterns below differ from older v1 tutorials.
What Crossplane adds to Kubernetes
Crossplane builds a platform API out of a few moving parts:
- Providers connect Crossplane to an external system such as AWS, Azure, or GCP.
- Managed resources are the concrete cloud objects Crossplane creates and reconciles in that system.
- Composite Resource Definitions (XRDs) define the schema of the platform API you want to offer.
- Compositions map that higher-level API to the managed resources underneath.
- Composite resources are the objects your internal users actually create.
That last point is worth being precise about on v2. Composite resources — and the managed resources behind them — are namespaced by default, and teams apply the composite directly. The older Claim (XRC) is now a legacy compatibility concept: it only applies under the v1-style LegacyCluster scope, so you do not need it for a new v2 API.
You can think of Crossplane as a translation layer with a control loop attached. An application team asks for a PostgresInstance; the platform team decides what that means in AWS, including the subnet group, security groups, parameter group, and connection secret wiring.
What a platform API looks like in practice
A good Crossplane API should look boring to the consumer. The whole point is to hide the cloud plumbing and expose a contract that is small enough to review in one glance.
You define that API once with a CompositeResourceDefinition (XRD) — on v2 that is apiextensions.crossplane.io/v2 with scope: Namespaced — declaring the PostgresInstance kind and its parameters schema. Once it is installed, a consumer just applies the composite resource:
apiVersion: platform.example.com/v1alpha1
kind: PostgresInstance
metadata:
name: payments-db
namespace: payments
spec:
parameters:
engineVersion: '15'
storageGB: 50
instanceClass: db.t3.medium
multiAZ: true
That is a useful platform interface because it expresses the decision the application team should own, without forcing them to learn every RDS detail. The platform team can encode the defaults once, keep the API stable, and stop re-litigating subnet groups and parameter groups in every provisioning request. (On v2 the composite no longer has a native connection-secret field; the Composition publishes the connection Secret itself, which is why it shows up as one of the composed resources below.)
How Compositions turn one resource into many
The Composition is where Crossplane stops being a thin wrapper and starts being a platform tool. It tells Crossplane how to take one composite resource and fan it out into the concrete resources that satisfy it.
A minimal example looks like this:
apiVersion: apiextensions.crossplane.io/v1 # the Composition is still v1, even on Crossplane v2
kind: Composition
metadata:
name: postgresinstance-aws
spec:
compositeTypeRef:
apiVersion: platform.example.com/v1alpha1
kind: PostgresInstance
mode: Pipeline
pipeline:
- step: patch-and-transform
functionRef:
name: function-patch-and-transform
input:
apiVersion: pt.fn.crossplane.io/v1beta1
kind: Resources
resources:
- name: subnet-group
base:
apiVersion: rds.aws.m.upbound.io/v1beta1 # v2 namespaced provider type
kind: SubnetGroup
- name: db
base:
apiVersion: rds.aws.m.upbound.io/v1beta1
kind: Instance
In v1 you would list these directly under spec.resources. v2 removed that built-in patch-and-transform mode, so a Composition is now a pipeline of functions — function-patch-and-transform is the drop-in for the old inline behaviour, and you install it as a Function package first. The example also assumes provider-aws v2.x: the namespaced *.m.upbound.io resource types (like rds.aws.m.upbound.io) only ship in the v2 provider family.
In a real platform API, you usually end up with more than just the database instance:
- a subnet group
- security groups or security group rules
- a parameter group
- secret publication for connection details
- patches and transforms that copy user input into the right provider fields
That extra YAML is the cost of keeping the consumer-facing API small and repeatable — and it lives in the Composition, where the platform team owns it, out of the app team’s way.
For anything more involved than field mapping, a Composition pipeline can chain several composition functions, including ones you write. There is no non-function mode left in v2 — patch-and-transform as a built-in was removed — which is another clue that Crossplane is best treated as platform engineering work, not as a quicker Terraform replacement.
The operating model is the real difference
HashiCorp still describes Terraform’s core workflow as write, plan, apply. That model is explicit and useful: review the plan, make the change, and stop there until the next run.
Crossplane behaves differently. Once the composite resource exists, Crossplane keeps reconciling towards the desired state. If someone changes a managed resource by hand, the controllers try to move it back. If a composed resource disappears, Crossplane recreates it. If the platform API changes, the control plane works that change through the managed resources underneath it.
That continuous reconciliation is the part that makes Kubernetes feel like the control plane for infrastructure rather than just the place your workloads happen to run.
It also layers neatly with GitOps. Argo CD can reconcile the PostgresInstance manifest into the cluster, while Crossplane reconciles the external resources behind it. Those are two separate loops doing two separate jobs, and together they remove a surprising amount of operational glue.
A provisioning request that used to sit in a ticket queue becomes a manifest, a review, and a controller run. The shape of the change matters more than any single number: fewer handoffs, fewer one-off scripts, and fewer cases where “please ask infra” is the only interface the app team gets.
Where Crossplane makes platform teams faster
Crossplane is a good fit when the same infrastructure pattern shows up again and again.
If five teams all need the same kind of PostgreSQL instance, cache, or message broker, you do not want five separate Terraform conversations. You want one opinionated API with guardrails built in.
That is where Crossplane earns its keep:
- the platform team owns defaults, policy, and cloud-specific wiring once
- application teams consume a namespaced Kubernetes resource instead of a ticket
- drift correction is automatic because reconciliation keeps running
- GitOps and RBAC stay in the same operational model as the rest of the cluster
Done well, that turns a repeated provisioning request into a product the platform team owns — without handing cloud infrastructure to every app team.
The sharp edges you still have to own
Crossplane is not free just because the consumer experience is cleaner.
The first trade-off is operational. You are now running another control plane with credentials that can write to production infrastructure. A bad Composition is not passive. It will reconcile the wrong thing very efficiently.
The second trade-off is debugging. Terraform usually fails at plan or apply time in one obvious place. Crossplane failures can span the composite resource, the Composition, the provider, the managed resource, and the external API. In practice, you end up checking events and conditions at several layers with commands like:
kubectl describe postgresinstance payments-db -n payments
kubectl get managed -A
kubectl describe clusterproviderconfig
The third trade-off is scope. Crossplane is strongest for repeatable platform primitives. If you are doing a one-off migration, a bootstrap step, or a piece of infrastructure that does not map cleanly onto a Kubernetes API, Terraform is usually simpler.
That boundary — a platform API versus general-purpose infrastructure automation — is the thing to get right.
When Terraform still wins
Terraform is still the better fit when:
- you need an explicit human-reviewed plan before anything changes
- the work is infrequent or one-off
- the resource graph does not map cleanly onto a platform API
- the team already has mature Terraform modules and the problem is not really self-service
If the question is “how do we create this safely once?”, Terraform is often the right answer.
If the question is “how do we let many teams request the same thing safely, repeatedly, and through Kubernetes?”, Crossplane is usually the better answer.
What to take away
The most useful way to think about Crossplane is as a way to make Kubernetes the control plane for a curated set of infrastructure APIs — not Terraform in another syntax.
If you do choose it, keep the consumer API boring. The more boring the resource is to apply, the better your platform abstraction is doing its job.