Crossplane vs Terraform: Composition vs HCL for Infrastructure as Code

Reading Time: 5 minutes

Kubernetes Ecosystem: From User to Contributor, Episode 7
← EP06: Crossplane · EP07: Crossplane vs Terraform · EP08: Karpenter →

11 min read


TL;DR

  • Crossplane vs Terraform is fundamentally a continuous-reconciliation model against a plan/apply model — not just two different syntaxes for the same idea
  • Crossplane needs a live Kubernetes cluster to run at all; Terraform needs nothing but a state file and network access to the providers it’s calling
  • Terraform’s provider registry is a decade deep and covers services Crossplane’s younger ecosystem hasn’t reached yet — SaaS tools, monitoring platforms, and services with no cloud-infrastructure angle at all
  • Crossplane’s Compositions give app teams a genuinely self-service, in-cluster API; Terraform’s modules give infra teams reusable code, but consuming a module still means running Terraform yourself
  • Recommendation: many real platform teams use both — Terraform (or CAPI, EP05) to bootstrap the cluster and its surrounding VPC/networking, then Crossplane running inside that cluster for the self-service, app-team-facing layer
  • Contribution opportunity: Crossplane’s provider coverage gap against Terraform’s registry is real, specific, and a legitimate place to build a brand-new provider

The Big Picture

TERRAFORM                                   CROSSPLANE
──────────                                   ──────────
terraform plan                               kubectl apply -f resource.yaml
  │  (shows what WOULD change)                    │
  ▼                                                ▼
terraform apply                              Crossplane controller reconciles
  │  (changes happen once, here)                   │  (continuously, forever,
  ▼                                                 │   not just at apply time)
State file (local or remote backend)               ▼
tracks what Terraform created                 Kubernetes etcd IS the state —
                                                the CR's status field tracks
No live cluster or control                     sync state
plane required to run this
                                               Requires a running Kubernetes
                                               cluster as the control plane

Crossplane vs Terraform is best understood through that control-flow difference first, before comparing any specific feature: Terraform changes things at discrete moments you trigger; Crossplane’s controllers are always watching, always correcting drift, for as long as the cluster runs.


The Fundamental Model Difference: Continuous Reconciliation vs Plan/Apply

Terraform’s model gives you an explicit review step — terraform plan shows exactly what will change before anything does, and nothing changes again until you run apply a second time. Crossplane’s model (covered in EP06) has no equivalent pause: once a Managed Resource or Composition claim exists, Crossplane’s controllers reconcile it toward the desired state continuously, including reverting manual out-of-band changes automatically.

Neither is objectively better — they’re suited to different operating assumptions. Terraform’s model fits teams who want a deliberate, reviewed change process. Crossplane’s fits teams who want infrastructure to behave like every other Kubernetes-native resource: self-healing, always converging, no separate “did anyone remember to re-apply” step.


State Management: etcd + CRDs vs Terraform State Files

# Terraform: state lives in a file (local or remote — S3, Terraform Cloud, etc.)
$ terraform state list
aws_s3_bucket.uploads
aws_db_instance.main

# Crossplane: "state" is just the live cluster's etcd — the CR's own status
$ kubectl get bucket uploads -o jsonpath='{.status.conditions}'
[{"type":"Ready","status":"True"},{"type":"Synced","status":"True"}]

Terraform’s state file is a single point of coordination that has to be locked correctly for concurrent runs to be safe — a well-understood but real operational concern (remote state backends, state locking, occasional manual state surgery after a botched apply). Crossplane sidesteps a separate state file entirely, but that means the health of your Kubernetes cluster’s etcd is the health of your infrastructure’s state — a different, not smaller, operational responsibility.


Composition vs Modules: Reusable Infrastructure Patterns Compared

Terraform modules are reusable code that whoever runs Terraform includes in their own configuration — genuinely reusable, but still something each consumer runs themselves. Crossplane Compositions (EP06) are reusable inside the cluster — an app team doesn’t run anything, they just create a custom resource the platform team already defined, and Crossplane’s controllers do the rest without the app team ever touching Terraform or Crossplane’s own tooling directly.

That’s the real practical difference for organizational self-service: Compositions remove the “app team needs to know how to run our IaC tool” step entirely. Modules still require the consumer to run Terraform, even if they didn’t write the module.


Ecosystem Maturity: Terraform’s Decade-Deep Provider Registry vs Crossplane’s Younger One

Terraform’s provider registry covers not just the major clouds but a long tail of SaaS platforms, monitoring tools, DNS providers, and internal enterprise systems that have no “cloud infrastructure” angle at all — a decade of community and vendor-contributed providers. Crossplane’s provider ecosystem, while actively growing and covering the major clouds thoroughly, has real, documented gaps once you look past core compute/storage/networking/database resources into more specialized or less common services.


The Recommendation: Which One, and When to Use Both

If your platform team is Kubernetes-native and wants to offer app teams a true self-service infrastructure API without teaching them a separate IaC tool: Crossplane. That’s the specific problem its Composition model solves better than anything Terraform offers.

If you need broad provider coverage beyond core cloud infrastructure, or you don’t want infrastructure lifecycle tied to a live Kubernetes control plane’s uptime: Terraform. Its registry depth and its independence from any running cluster are real advantages Crossplane doesn’t currently match.

The honest answer for a lot of real platform teams is both, at different layers. Use Terraform (or Cluster API, EP05) to bootstrap the Kubernetes cluster itself and its surrounding cloud networking — the layer that has to exist before Crossplane can run at all — then run Crossplane inside that cluster for the ongoing, self-service, app-team-facing infrastructure requests. This isn’t a compromise; it’s matching each tool to the layer it’s actually better suited for.


⚠ Production Gotchas

Don’t manage the same cloud resource with both Terraform and Crossplane simultaneously. Both tools will detect the other’s changes as drift and fight to revert them — pick one owner per resource, even when both tools are in use across your stack at different layers.

Terraform’s plan/apply gives you a review window Crossplane doesn’t — build your own review gate if you need one with Crossplane (a PR-based GitOps flow with required approval before a claim manifest merges is the common substitute).

Crossplane’s continuous reconciliation means a broken provider or a cloud API outage shows up as a stuck Synced: False condition, not a failed one-time command — monitoring needs to watch for stuck conditions over time, not just command exit codes the way Terraform CI pipelines typically do.


Quick Reference

Terraform Crossplane
Change model Plan → Apply (explicit) Continuous reconciliation
Requires a live cluster No Yes
State State file (local/remote) Kubernetes etcd + CR status
Reusable patterns Modules (you still run them) Compositions (app team just creates a claim)
Provider breadth Very broad, decade-deep Growing, strong on core cloud, gaps elsewhere
Manual drift Detected at next plan, not auto-reverted Auto-reverted on next reconcile

Contribution Opportunity: Building a Missing Crossplane Provider

The limitation: For a meaningful number of services Terraform has supported for years — smaller SaaS platforms, specialized monitoring tools, niche infrastructure services — there’s no Crossplane provider equivalent yet. Anyone wanting to manage that service the Crossplane way currently can’t, full stop.

Why it’s hard to fix: Building a new provider means implementing a real API client, defining CRD schemas that faithfully map the service’s actual parameters, and maintaining it as that service’s API evolves — real, ongoing engineering commitment, not a one-time script. That’s exactly why the ecosystem’s provider list still trails Terraform’s, despite Crossplane’s core reconciliation engine being mature: the core is one thing to maintain, but each provider is its own ongoing surface area.

What a contribution-shaped fix looks like: Crossplane’s provider-template repository exists specifically to make starting a new provider tractable — it scaffolds the boilerplate (code generation, CRD structure, controller wiring) so a new provider author focuses on the actual API mapping, not plumbing. Picking one service you already use via Terraform that has no Crossplane equivalent, and building a minimal provider covering just the 2-3 resource types you actually need, is a real, bounded, achievable contribution — and one the Crossplane community actively wants, given how directly it grows the ecosystem.


Key Takeaways

  • Crossplane’s continuous reconciliation and Terraform’s plan/apply are different operating models, not different syntaxes for the same thing — pick based on which review/change process fits your team
  • Crossplane requires a live cluster to function at all; Terraform doesn’t, which matters for bootstrapping order
  • Compositions remove the “app team has to run our IaC tool” step that Terraform modules still require
  • Terraform’s provider registry breadth remains a real advantage for anything beyond core cloud infrastructure
  • Many real platform teams run both at different layers — Terraform/CAPI to bootstrap the cluster, Crossplane inside it for self-service — and that’s a legitimate architecture, not indecision

What’s Next

Everything so far in this series has been about provisioning clusters and the infrastructure around them. EP08 shifts to what happens inside an already-running cluster when pods can’t be scheduled: Karpenter’s just-in-time node provisioning, and why it replaced the node-group model most teams started with.

Next: EP08 — Karpenter: Just-in-Time Node Provisioning for Kubernetes

Get EP08 in your inbox when it publishes → linuxcent.com/subscribe

Crossplane: Kubernetes as the Universal Control Plane

Reading Time: 5 minutes

Kubernetes Ecosystem: From User to Contributor, Episode 6
← EP05: Cluster API · EP06: Crossplane · EP07: Crossplane vs Terraform →

12 min read


TL;DR

  • Crossplane extends the exact reconciliation pattern EP05 covered for cluster infrastructure to any cloud resource — an S3 bucket, an RDS instance, a DNS record all become Kubernetes CRDs, continuously reconciled
  • Managed Resources represent one real cloud resource each; Compositions bundle several Managed Resources behind a single, simpler custom API a platform team defines and app teams consume
  • Composition Functions are Crossplane’s newer, more flexible replacement for its older YAML-based patch-and-transform templating — real code (Go, Python, or others) instead of declarative patches
  • Crossplane continuously reconciles like any Kubernetes controller — a manual change to a cloud resource outside Crossplane gets reverted on the next reconcile loop, which is a real surprise for teams used to Terraform’s plan/apply model
  • Provider CRD counts can bloat a cluster’s etcd significantly — this drove the ecosystem’s move toward smaller, split “provider families” instead of one monolithic provider per cloud
  • Contribution opportunity: several providers still haven’t migrated to the family-split pattern — a real, currently-tracked, achievable upstream contribution

The Big Picture

App team writes:                    Platform team defined this Composition
                                     once, behind the scenes:
apiVersion: platform.example.com/v1
kind: Database                       XRD "Database" ─── composes ───┐
metadata:                                                             │
  name: my-app-db                                                     ▼
spec:                                                        ┌────────────────┐
  size: small                                                │ RDSInstance    │
                                                                │ SecurityGroup  │
   │                                                            │ ParameterGroup │
   │ app team never sees                                        └────────────────┘
   │ or touches these three                                     each a real Managed
   ▼                                                            Resource, a real
Crossplane reconciles all three,                                cloud API call
continuously, forever

Crossplane’s pitch as a universal control plane is literal: instead of app teams filing tickets or writing their own Terraform for a database, they request a Database — a custom API the platform team designed — and Crossplane’s controllers translate that into the actual RDS instance, security group, and parameter group underneath, then keep reconciling all three toward the declared state indefinitely.


Managed Resources: Cloud Infrastructure as Kubernetes CRDs

$ kubectl apply -f - <<EOF
apiVersion: s3.aws.upbound.io/v1beta1
kind: Bucket
metadata:
  name: app-uploads-prod
spec:
  forProvider:
    region: us-east-1
  providerConfigRef:
    name: aws-prod
EOF

$ kubectl get bucket app-uploads-prod
NAME               READY   SYNCED   AGE
app-uploads-prod   True    True     30s
#                  ^^^^    ^^^^^^ — READY: resource exists and is healthy
#                          SYNCED: Crossplane's last reconcile succeeded

Every field under forProvider maps directly to that cloud API’s actual parameters — this is a thin, honest translation layer, not an abstraction hiding what’s actually being created. READY/SYNCED becoming True means an actual S3 bucket now exists in that AWS account, exactly as declared.


Compositions and XRDs: Building Your Own Abstract Platform API

This is Crossplane’s real differentiator over just using individual Managed Resources directly:

# The platform team defines the abstract API app teams will see
apiVersion: apiextensions.crossplane.io/v1
kind: CompositeResourceDefinition
metadata:
  name: xdatabases.platform.example.com
spec:
  group: platform.example.com
  names:
    kind: XDatabase
    plural: xdatabases
  claimNames:
    kind: Database        # ← this is what app teams actually create
    plural: databases
  versions:
  - name: v1
    schema:
      openAPIV3Schema:
        properties:
          spec:
            properties:
              size: {type: string, enum: ["small", "medium", "large"]}

App teams interact only with the simple Database claim shown in the Big Picture diagram above. The Composition resource (not shown here for brevity) is what actually maps size: small to a specific RDS instance class, storage size, and backup configuration — the platform team’s opinions, encoded once, consumed self-service by every app team afterward.


Composition Functions: Crossplane’s Newer, More Flexible Approach

Older Crossplane Compositions used a YAML-based “patch and transform” templating language to map the abstract API’s fields onto Managed Resource fields — functional, but limited for anything beyond straightforward field mapping. Composition Functions replace that with actual executable code:

$ crossplane beta render xr.yaml composition.yaml functions.yaml
---
apiVersion: rds.aws.upbound.io/v1alpha1
kind: Instance
metadata:
  name: my-app-db-instance
spec:
  forProvider:
    instanceClass: db.t3.micro   # ← computed by real Go logic based on
                                  #   spec.size, not a static YAML patch
    engine: postgres

Composition Functions run as small, packaged pieces of logic (often distributed as OCI images) that Crossplane’s engine invokes during reconciliation — giving platform teams real conditionals, loops, and validation instead of the older templating language’s more limited patch syntax.


Providers and the Provider Ecosystem

Each cloud’s resources are supplied by a separate providerprovider-aws, provider-gcp, provider-azure, and increasingly split into smaller provider families (provider-aws-s3, provider-aws-rds, etc.) rather than one enormous provider per cloud:

$ kubectl get providers
NAME                   INSTALLED   HEALTHY   AGE
provider-aws-s3        True        True      10d
provider-aws-rds       True        True      10d
#         ^^^^^^ — installing only the families you actually use, instead
#                  of one monolithic provider-aws with every AWS service's
#                  CRDs installed regardless of whether you use them

The family split exists specifically because a single monolithic cloud provider can register thousands of CRDs — a real, measurable strain on a cluster’s etcd and API server that the ecosystem is still in the process of migrating away from.


⚠ Production Gotchas

Crossplane reconciles continuously — a manual change to a cloud resource outside Crossplane gets reverted on the next loop. Teams coming from Terraform’s plan/apply model, where nothing changes until you explicitly run apply again, are frequently surprised the first time a manual “quick fix” in the AWS console gets silently undone minutes later.

Monolithic providers can register thousands of CRDs, and that has a real, measurable etcd and API-server cost. If you’re on an older, non-family provider version and seeing API server memory pressure, check CRD count before assuming it’s an unrelated capacity issue.

Deleting a Composition’s underlying claim doesn’t always tear down cleanly if finalizers on the Managed Resources are stuck — a Managed Resource that failed to delete cleanly from the cloud side (a non-empty S3 bucket, for instance) will block the whole claim’s deletion until that’s resolved manually.


Quick Reference

kubectl get managed                        # every Managed Resource, all providers
kubectl get compositeresourcedefinitions   # XRDs — the abstract APIs defined
kubectl get compositions                   # the mapping logic behind each XRD
kubectl get providers                       # installed providers + health
crossplane beta render <xr> <comp> <fns>    # render a Composition locally, no cluster needed
kubectl describe <managed-resource-kind> <name>   # sync status + underlying cloud errors

Contribution Opportunity: Migrating Providers to the Family Pattern

The limitation: Not every Crossplane provider has migrated from the older, monolithic-per-cloud model to the smaller “provider family” pattern that registers only the CRDs for services actually in use. Clusters running an un-migrated provider carry the etcd and API-server overhead of thousands of unused CRDs, and this is a known, actively-discussed problem in the Crossplane community — not a hypothetical one.

Why it’s hard to fix: Splitting a monolithic provider into families isn’t a mechanical find-and-replace — it means restructuring code generation, versioning, and release processes for every resource type the provider covers, while keeping a migration path that doesn’t break existing users who depend on the old provider’s CRDs. It’s real, unglamorous engineering work that has to happen provider-by-provider, cloud-by-cloud, and each provider’s maintainer bandwidth varies.

What a contribution-shaped fix looks like: The Crossplane and Upbound-maintained provider repositories publicly track which providers still need family-splitting — this is documented, wanted work, not a gap you’d have to go discover yourself. A concrete starting contribution: pick one still-monolithic provider (checking the project’s own tracking issues for an unclaimed one), and work through the documented family-split process the already-migrated providers (like provider-aws) used as a reference implementation. This is real upstream OSS work with an existing template to follow, not a design problem you have to solve from scratch.


Key Takeaways

  • Crossplane’s Managed Resources make individual cloud resources real Kubernetes CRDs, continuously reconciled rather than applied once
  • Compositions and XRDs are the actual value proposition: platform teams define a simple, opinionated API once; app teams self-serve against it without needing to know what’s underneath
  • Composition Functions replace older YAML patch-and-transform templating with real executable logic — a genuinely evolving, more flexible part of the project
  • Continuous reconciliation means manual out-of-band changes get reverted — a real behavioral difference from Terraform’s plan/apply model, not just a implementation detail
  • The provider family migration is documented, wanted, achievable contribution work — not a gap you’d need to discover on your own

What’s Next

Crossplane’s composition model and Terraform’s HCL module model solve the same underlying problem — reusable, parameterized infrastructure definitions — from genuinely different architectural starting points. EP07 puts them side by side and gives a clear recommendation for which fits which team.

Next: EP07 — Crossplane vs Terraform: Composition vs HCL for Infrastructure as Code

Get EP07 in your inbox when it publishes → linuxcent.com/subscribe