background-shape
Crossplane vs Terraform for Platform Teams
January 15, 2024 · 7 min read · by Muhammad Amal programming

TL;DR — Terraform 1.6 is still the right answer for one-off infra and team-owned stacks. Crossplane v1.14 wins when you’re exposing cloud resources as platform APIs to other teams. The choice isn’t either/or in 2024 — it’s where each one sits in your stack.

If you read golden paths, the obvious next question is what generates the AWS, GCP, or Azure resources behind those paths. Both Terraform and Crossplane will do it. Both have advocates who’ll insist the other is obsolete. Neither claim is true.

I’ve shipped platforms using both. I’ve also seen teams pick wrong and bleed for a year. The honest version of this comparison is that they solve overlapping but distinct problems. Get the framing right and the choice falls out.

The mental model

Terraform is a declarative executor. You write HCL, run plan, run apply, and Terraform reconciles cloud state to your config at that moment. Drift detection is a separate run. State is a file you have to manage.

Crossplane is a Kubernetes controller. You install providers, write Kubernetes custom resources that describe cloud infrastructure, and a control loop continuously reconciles cloud state to those resources. Drift is corrected on the next reconciliation. There is no state file because the cluster is the state.

That distinction is the whole game. Terraform is point-in-time. Crossplane is continuous.

# Terraform 1.6 — point-in-time
resource "aws_db_instance" "orders" {
  identifier        = "orders-prod"
  engine            = "postgres"
  engine_version    = "16.1"
  instance_class    = "db.t4g.medium"
  allocated_storage = 50
  storage_encrypted = true
  username          = "orders"
  password          = var.db_password
  skip_final_snapshot = false
}
# Crossplane v1.14 — continuous reconciliation
apiVersion: rds.aws.upbound.io/v1beta1
kind: Instance
metadata:
  name: orders-prod
spec:
  forProvider:
    region: us-east-1
    engine: postgres
    engineVersion: "16.1"
    instanceClass: db.t4g.medium
    allocatedStorage: 50
    storageEncrypted: true
    username: orders
    skipFinalSnapshot: false
    passwordSecretRef:
      namespace: orders
      name: db-credentials
      key: password
  providerConfigRef:
    name: aws-orders

Looks similar. The behavior is different in ways that matter.

Where Terraform still wins

Anyone who tells you Terraform is dead hasn’t operated either tool at scale. Terraform 1.6 (released October 2023) added test framework support and a few quality-of-life improvements. The state migration story is mature. The community module ecosystem is enormous. You can hire engineers who already know it.

Terraform is the right answer when:

  • A small group of engineers owns and changes the infra. The plan/apply loop with human review is a feature, not a bug, when humans should be in the loop.
  • The infrastructure is changed rarely. Drift is unlikely because nothing else is writing to it.
  • You’re working across providers that don’t have great Crossplane support yet (some SaaS vendors).
  • The team has zero Kubernetes operational expertise. Adopting Crossplane pulls a Kubernetes dependency you can’t avoid.
  • You need the planning ceremony — terraform plan output reviewed in PRs is genuinely useful for change management.

I still use Terraform for foundational pieces — VPCs, IAM roles, account bootstrapping. Things that change once a quarter and where I want a human to read the diff.

Where Crossplane wins

Crossplane wins when you need to expose cloud resources as APIs that other teams self-service. That’s exactly the platform engineering use case.

Concrete scenarios:

  • A team needs a Postgres database via the developer portal. The portal creates a Database CR. Crossplane provisions the RDS instance, sets up parameter groups, configures backups, and writes the connection string back to a Kubernetes Secret. The user never sees AWS.
  • A drift detection that runs every 60 seconds, not when someone remembers to run plan.
  • Composition — you define a PlatformDatabase API that under the hood creates an RDS instance, a parameter group, a security group, and an entry in your secret manager. Application teams consume the high-level API.
# A Composition — Crossplane v1.14
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
  name: platform-postgres
spec:
  compositeTypeRef:
    apiVersion: platform.acme.io/v1alpha1
    kind: XDatabase
  resources:
    - name: db-instance
      base:
        apiVersion: rds.aws.upbound.io/v1beta1
        kind: Instance
        spec:
          forProvider:
            engine: postgres
            engineVersion: "16.1"
            instanceClass: db.t4g.medium
            storageEncrypted: true
            multiAz: true
    - name: subnet-group
      base:
        apiVersion: rds.aws.upbound.io/v1beta1
        kind: SubnetGroup
    - name: param-group
      base:
        apiVersion: rds.aws.upbound.io/v1beta1
        kind: ParameterGroup

A developer writes:

apiVersion: platform.acme.io/v1alpha1
kind: Database
metadata:
  name: orders-db
  namespace: orders
spec:
  size: small
  engineVersion: "16.1"

Crossplane composes that into the four underlying resources, provisions them, and feeds the connection details back into the namespace. That’s the IDP API the developer-facing portal calls.

The v1.14 provider-family change

The biggest 2023-into-2024 architectural shift in Crossplane is the provider-family architecture, which became the default with the Upbound-maintained providers and stabilized through v1.14.

Old model: one provider-aws container with every AWS resource type baked in. Big container, slow startup, every resource kind sharing one controller process.

New model: per-service-family providers. provider-family-aws for shared auth, then provider-aws-rds, provider-aws-ec2, provider-aws-iam, etc. You install only the families you use. Each one has its own controller.

apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
  name: provider-aws-rds
spec:
  package: xpkg.upbound.io/upbound/provider-aws-rds:v1.0.0

Practical impact: faster reconciliation, lower memory pressure, smaller blast radius when a provider has a bug, and CRD installation count drops by an order of magnitude. The old monolithic provider used to install >800 CRDs on startup. The new approach lets you have <100 if you only use RDS, EC2, and IAM.

If you piloted Crossplane in 2022 and shelved it because of the controller memory footprint, the 2024 architecture is worth a second look.

The drift question

This is where I see the most confused decisions. Both tools handle drift. They handle it differently.

Terraform: drift is detected on the next plan. If nothing runs plan, nothing detects drift. There are paid offerings (Spacelift, Terraform Cloud) that run drift detection on a cron. You can roll your own with a scheduled GitHub Action.

Crossplane: drift is corrected continuously by the controller. If someone clicks a checkbox in the AWS console, the controller will revert it within a minute. This is great for platform-managed resources where humans shouldn’t be poking at the console. It’s terrifying when you’ve manually fixed something and a controller silently undoes you.

The Crossplane behavior is configurable per-resource via managementPolicies (stable in v1.14):

spec:
  managementPolicies: ["Observe"] # detect drift but don't reconcile

Or:

spec:
  managementPolicies: ["Observe", "Create", "Update", "LateInitialize"] # don't delete

That second policy set is what I use for any resource I want Crossplane to provision and update but never delete. It saved me from a kubectl delete outage that would otherwise have torn down a production RDS instance.

Practical recommendation for 2024

For most platform teams in 2024:

  • Terraform for the foundation — accounts, networks, identity, anything provisioned once and rarely changed.
  • Crossplane for the resources you expose to internal customers via the IDP — databases, caches, buckets, queues.

The boundary is roughly: if a stream-aligned team needs to provision it themselves via the portal, Crossplane. If a platform engineer provisions it once and forgets it, Terraform.

You’re not picking sides. You’re picking a layer for each tool.

Common Pitfalls

  • Trying to manage everything in Crossplane on day one. The CRD explosion in the old architecture made this brutal. Even with provider families it’s overkill. Start narrow.
  • Storing Terraform state in Git. Use a real backend — S3 with DynamoDB locking, GCS, or a hosted provider. State in Git is a footgun.
  • Letting Crossplane delete resources on namespace deletion. Set deletionPolicy: Orphan on resources whose loss would be catastrophic.
  • Treating Crossplane Compositions as user-editable. Compositions are a platform-team artifact. Application teams interact with the XR (the composite resource), not the Composition.
  • Skipping the provider auth setup. The ProviderConfig is per-provider and easy to misconfigure. Verify it lands the right IAM role before you build on top.

The pitfall I personally tripped over: I migrated a project from Terraform to Crossplane in one swoop and discovered the hard way that Crossplane’s reconciliation broke a manual configuration drift the team had been relying on for months. Migrate one resource type at a time.

Wrapping Up

Crossplane vs Terraform isn’t a religious war in 2024. It’s a layering decision. Terraform handles the foundation that humans change rarely. Crossplane handles the abstractions developers consume daily. Pick both, give each a clear scope, and most of the friction disappears.

If you’re new to Crossplane, the official getting started guide covers a Compositions walkthrough that’s worth an hour. Next post in the series digs into one of the most under-loved parts of Backstage — the service catalog, and how to design one that doesn’t immediately rot.