Crossplane vs Terraform for Platform Teams

Every platform team eventually asks the same question: should we keep provisioning infrastructure with Terraform, or move to Crossplane and run it all through Kubernetes? The two tools solve the same broad problem, declaring infrastructure as code, but they disagree about almost everything else. Understanding that disagreement is more useful than any feature checklist.

The fundamental difference #

Terraform is a CLI-driven, run-based tool. You write HCL, run terraform plan to preview a diff against a state file, then terraform apply to make reality match. Nothing happens between runs. The state file is the source of truth about what Terraform believes exists, and a human or a pipeline decides when to reconcile.

Crossplane is a Kubernetes control plane. You install it into a cluster, and it extends the Kubernetes API with Custom Resource Definitions that represent cloud infrastructure. A managed resource like an RDS instance becomes a Kubernetes object. Controllers watch those objects and continuously drive the real cloud resource toward the declared spec. There is no apply command and no separate state file. The cluster's etcd is the state, and reconciliation is always running.

Run-based vs continuous: Terraform reconciles when you tell it to. Crossplane reconciles on a loop, forever, every few minutes.

Continuous reconciliation and drift #

This is the headline distinction. With Terraform, if someone edits a security group in the console at 2am, nothing notices until the next plan runs and shows the diff. Whether that diff gets corrected depends on your pipeline cadence and whether anyone reads the output.

Crossplane treats that same console edit as drift and corrects it automatically on the next reconcile loop. The declared spec wins. For teams that want infrastructure to be genuinely immutable against manual changes, this is a real advantage. It is also a real hazard: if someone made that change deliberately during an incident, Crossplane will happily revert it while you are still firefighting. Continuous reconciliation is a policy, not just a feature, and you need to decide whether you want it.

Who Crossplane is actually for #

Crossplane is aimed at platform teams building internal platforms, not at individuals provisioning a handful of resources. Its real value shows up when you use Composite Resource Definitions (XRDs) and Compositions to build your own abstractions. You define a high-level API, say a PostgreSQLInstance, and a Composition that expands it into the actual RDS instance, subnet group, parameter group, and security rules. Application teams then request a database by submitting a small Claim, never touching the underlying cloud detail.

That self-service story is the point. If you are not building an abstraction layer for other teams to consume, most of Crossplane's machinery is overhead you will not use.

Here is a trimmed Composition and the Claim a developer would submit:

yaml.yaml

apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: postgres.aws
spec:
compositeTypeRef:
apiVersion: platform.acme.io/v1alpha1
kind: XPostgreSQLInstance
resources:
- name: rds-instance
base:
apiVersion: rds.aws.upbound.io/v1beta1
kind: Instance
spec:
forProvider:
region: us-east-1
engine: postgres
engineVersion: "15"
instanceClass: db.t3.medium
allocatedStorage: 20
skipFinalSnapshot: true
patches:
- fromFieldPath: spec.parameters.storageGB
toFieldPath: spec.forProvider.allocatedStorage
---
apiVersion: platform.acme.io/v1alpha1
kind: PostgreSQLInstance
metadata:
name: orders-db
spec:
parameters:
storageGB: 50
compositionRef:
name: postgres.aws

The developer sees only storageGB. Everything else is the platform team's opinion, encoded once.

The Terraform equivalent puts the same opinion in a module:

hcl.hcl

resource "aws_db_instance" "postgres" {
identifier = "orders-db"
engine = "postgres"
engine_version = "15"
instance_class = "db.t3.medium"
allocated_storage = var.storage_gb
skip_final_snapshot = true
}

variable "storage_gb" {
type = number
default = 20
}

Both encode the same abstraction. The difference is delivery: the Terraform module is consumed by running Terraform, while the Crossplane Claim is consumed by submitting YAML to an API that is always on.

The operational cost of running Crossplane #

Terraform, at its simplest, is a binary you run. Crossplane is a distributed system you operate. That means a Kubernetes cluster dedicated to being a control plane, provider pods for each cloud, credentials management, upgrades of Crossplane and every provider, and monitoring of reconcile loops that can silently fall behind. When a provider pod crashes or a reconcile stalls, your infrastructure changes stop happening, and diagnosing why means reading controller logs and Kubernetes events.

This is not a reason to avoid Crossplane, but it is an honest line item. You are trading Terraform's operational simplicity for a platform you have to keep alive.

RBAC and GitOps fit #

Because Crossplane resources are Kubernetes objects, they inherit the ecosystem. Kubernetes RBAC governs who can create a database Claim. Argo CD or Flux can sync your infrastructure manifests the same way they sync application manifests, and drift correction comes for free because that is what GitOps controllers and Crossplane both do. For a team already all-in on GitOps, Crossplane feels native.

Terraform's GitOps story exists through Atlantis, Terraform Cloud, or CI pipelines, but it is bolted on rather than inherent. The plan/apply gate is a strength here: you get an explicit, human-reviewable diff before anything changes, which many teams value more than automatic reconciliation.

Maturity and learning curve #

Terraform has a decade of adoption, a vast provider registry, mountains of documented patterns, and engineers who already know it. Crossplane is younger. Its providers, especially the Upbound-generated ones, cover most services but occasionally lag. The learning curve is steeper because you are learning Crossplane and Kubernetes API mechanics at once, and Compositions have real conceptual weight.

Making the decision #

Choose Terraform when your team is provisioning infrastructure for itself, wants explicit plan/apply gates, values the mature ecosystem, or does not want to operate a control plane. This covers most teams honestly.

Choose Crossplane when you are a platform team building a self-service internal platform, you are already committed to Kubernetes and GitOps, and you specifically want continuous reconciliation and API-driven abstractions that other teams consume without touching cloud detail.

Many organizations run both: Terraform bootstraps the cluster and foundational accounts, and Crossplane runs on top to offer self-service resources. That is a legitimate architecture, not a compromise.

For a broader survey of where each fits alongside Pulumi, OpenTofu, and the rest, see our roundup of the best Infrastructure-as-Code tools.

The call we'd make #

For most teams, Crossplane is not a Terraform replacement, and treating it as one leads to pain. Terraform remains the default for provisioning. Reach for Crossplane specifically when you are building a product for other engineers, an internal platform with self-service APIs, continuous reconciliation, and a Kubernetes-native surface. If that is your mandate, Crossplane's model pays off. If it is not, Terraform will do the job with far less to operate.