platform· 9 min read

Terraform vs Crossplane

The comparison is usually framed as a choice. It is more useful to frame it as a question about timing: when does your infrastructure get reconciled with its desired state?

Terraform reconciles when someone runs it. Crossplane reconciles continuously. Almost every real difference falls out of that.

What each one actually is

Terraform is a CLI that reads declarative config, diffs it against recorded state, and produces a plan of API calls. The plan is the product. A human reads it before anything changes. State lives in a file you are responsible for storing and locking.

Crossplane is a set of Kubernetes controllers. Infrastructure becomes custom resources; controllers reconcile them against the provider’s API on a loop, forever. There is no separate state store. The cluster is the state, same as any other Kubernetes object.

Drift

This is the sharpest difference and the one worth deciding on first.

Someone opens the AWS console and widens a security group at 2am during an incident.

With Terraform, that change persists until the next plan runs. You find out at the next apply (could be an hour, could be next sprint) and it surfaces as a diff in a plan that also contains three unrelated changes, which is exactly when people skim.

With Crossplane, the controller notices within its poll interval and puts it back. The console change simply does not survive.

Whether that is good depends entirely on your incident culture. Continuous reconciliation is a strong guarantee, and it will also fight an engineer who is mid-incident and does not know Crossplane owns that resource. Both properties are real. Teams that adopt Crossplane successfully tend to pair it with a documented break-glass path: annotate the resource to pause reconciliation, fix the thing, then fix the config.

Plan is a real advantage

Terraform’s plan output has no clean Crossplane equivalent, and this matters more than the feature list suggests.

terraform plan tells you this will destroy and recreate your database before it happens. That preview is a genuine safety mechanism and it is the reason Terraform remains the sensible choice for foundational infrastructure. VPCs, subnets, IAM, anything where an accidental replacement is a very bad afternoon.

Crossplane applies a resource and you watch the status conditions to see what happened. Tooling has improved (you can dry-run against the API server, and composition functions can be tested in isolation) but there is nothing that shows you the full blast radius of a change before you commit it. Push a bad composition and it starts reconciling immediately across every claim that uses it.

Abstraction is where Crossplane earns its place

Terraform’s unit of reuse is the module. It works, but consuming a module means running Terraform: a pipeline, credentials, state access, and a plan somebody approves. That is a reasonable amount of machinery to hand to a product team who wants a Postgres instance.

Crossplane’s CompositeResourceDefinition creates an actual Kubernetes API. You define the interface your teams see:

apiVersion: apiextensions.crossplane.io/v1
kind: CompositeResourceDefinition
metadata:
  name: xpostgresinstances.platform.example.com
spec:
  group: platform.example.com
  names:
    kind: XPostgresInstance
    plural: xpostgresinstances
  versions:
    - name: v1alpha1
      served: true
      referenceable: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                size:
                  type: string
                  enum: [small, medium, large]
                version:
                  type: string
              required: [size]

A Composition maps size: small onto a concrete RDS instance, parameter group, subnet group, and security group. The team writes eight lines of YAML into their own repo, and ArgoCD applies it like anything else. No Terraform, no state, no pipeline credentials, no ticket.

That is the golden-path story, and it is substantially harder to build with Terraform alone. The team gets kubectl get postgresinstance, RBAC, and the same GitOps flow they already use for Deployments.

The cost is real: you are now maintaining an API. Schema changes need versioning. A bad Composition affects every consumer. You have taken on the responsibilities of an API provider, which is more work than publishing a module.

Ecosystem

Terraform’s provider coverage is far ahead, and this is not close. If you need to configure a niche SaaS, Terraform probably has a provider and Crossplane probably does not.

Crossplane’s provider situation improved considerably with Upjet, which generates providers from Terraform providers. The major cloud providers are well covered and generated from the same upstream schemas. Coverage outside the big three is thinner.

Also worth knowing: Terraform’s licence changed to BUSL in 2023, prompting the OpenTofu fork. If licensing matters to your organisation, that is now a decision you have to make explicitly rather than a default.

Where the line usually lands

The split most platform teams converge on:

Terraform for foundations. Accounts, VPCs, subnets, transit gateways, IAM roles, EKS clusters themselves, DNS zones. Changes rarely, changes dangerously, benefits from a plan someone reads. Run by the platform team from a pipeline.

Crossplane for the self-service layer. Databases, queues, buckets, cache instances: the things product teams request often and that fit a small number of shapes. Run continuously, consumed by teams through your own APIs.

There is a bootstrap ordering here that is easy to get wrong: Terraform builds the cluster, then Crossplane runs on that cluster and provisions everything else. Do not use Crossplane to manage the cluster it runs on.

Choosing, if you must choose one

Terraform only. You have no Kubernetes, or a small team, or infrastructure that changes rarely and is managed by the people who own it. Adding a control plane to run one team’s infrastructure is not worth it.

Crossplane only. Everything already runs on Kubernetes, GitOps is how you deploy, and your main pain is that product teams have to raise tickets for infrastructure. That last symptom is the strongest signal.

The failure mode worth avoiding is adopting Crossplane for the technology rather than the abstraction. If you use it to write one custom resource per cloud resource, you have taken on a control plane and gained approximately nothing over Terraform. The value is in the Compositions. In the fact that a team asks for size: small and never learns what an RDS parameter group is.