root/software-architecture/the-internal-developer-platform-idp-part-4-crossplane-compositions-vs-terraform-modules

The Internal Developer Platform – Part 4: Crossplane Compositions vs Terraform Modules

A technical comparison of Crossplane Compositions and Terraform modules for building multi-cloud IDP provisioning abstraction layers.

Project Series: The Internal Developer Platform - IDP

The Internal Developer Platform – Part 4: Crossplane Compositions vs Terraform Modules
08/07/2026|5 min read|

Every Internal Developer Platform eventually hits the same wall: the abstraction layer that exposes infrastructure to application teams becomes either too rigid or too leaky. Platform teams building self-service provisioning on top of raw terraform apply pipelines discover that state files do not compose the way object-oriented engineers expect, while teams adopting Kubernetes-native control planes discover that reconciliation loops introduce a different class of failure entirely. This is Part 4 of our IDP series, and we are addressing the decision point that determines the entire lifecycle of your platform: do you build the resource abstraction layer with Crossplane Compositions or with layered Terraform modules?

This is not a popularity contest. Both tools solve infrastructure-as-code, but they solve abstraction using fundamentally different execution models — one is a continuously reconciling control plane, the other is a stateful, run-to-completion CLI tool wrapped in CI. Choosing incorrectly at the IDP layer means re-architecting your golden paths eighteen months in, once fifty microservice teams depend on the API you exposed.

#The Core Bottleneck: State Model vs Control Loop

Terraform’s execution model is fundamentally imperative-declarative hybrid: you declare desired state, but reconciliation happens only at the moment terraform apply executes. Between runs, infrastructure can drift silently — a misconfigured security group opened manually in the AWS console persists until the next plan detects it. For a platform team, this means your abstraction layer (the Terraform module) is only as trustworthy as your CI cadence.

Crossplane inverts this entirely. It runs as a set of controllers inside a Kubernetes control plane, continuously reconciling Managed Resources against their desired spec, typically on a 60-second poll interval by default. When you build Crossplane Compositions, you are not writing a script that runs on demand — you are defining a Kubernetes API extension that a controller enforces indefinitely. Drift is corrected automatically, not caught retrospectively.

This distinction is the single biggest architectural fork in the road when designing the infrastructure abstraction layer for an IDP, and it dictates everything downstream: how you handle secrets, how you model multi-cloud parity, and how you expose self-service APIs to product engineers.

#Architectural Breakdown

#Terraform Modules as the Abstraction Unit

A Terraform-based IDP abstraction layer typically consists of versioned modules published to a private registry, consumed via a thin wrapper (often generated by tools like Terragrunt or Atlantis-driven PR workflows). The module is the API contract: input variables define the interface, outputs expose consumable attributes. Multi-cloud abstraction is achieved by writing parallel modules — module/network/aws, module/network/azure — behind a common variable schema, with the platform’s service catalogue selecting the correct module at generation time.

The weakness here is state management at scale. Each module invocation produces a state file that must be locked (typically via an S3 backend with DynamoDB locking, or Terraform Cloud’s remote state), and cross-module dependencies require either terraform_remote_state data sources or a monolithic root module — both of which erode the isolation boundaries an IDP needs between tenant teams.

#Crossplane Compositions as the Abstraction Unit

Crossplane restructures the abstraction entirely around Kubernetes’ extension mechanism. A CompositeResourceDefinition (XRD) defines the schema of your platform API — for example, XPostgreSQLInstance — and a Composition maps that abstract schema onto one or more concrete Managed Resources from a provider (provider-aws, provider-azure, provider-gcp). Developers interact with a Claim, a namespaced resource that references the XRD, giving you native multi-tenancy through Kubernetes RBAC and namespace isolation rather than bespoke state file segregation.

Crossplane Compositions

Because Crossplane Compositions live inside the same etcd-backed control plane as your workloads, you get GitOps compatibility for free via tools like Argo CD — the desired state of infrastructure and application manifests live in the same reconciliation paradigm. This is architecturally elegant for platform teams standardising on Kubernetes as the control plane, but it introduces a hard dependency: your infrastructure abstraction layer is now coupled to cluster uptime and controller health.

#Implementation Logic: Building a Multi-Cloud Provisioning Layer

Assume the platform requirement is a self-service “managed database” claim that provisions RDS on AWS or Azure Database for PostgreSQL depending on the target region’s cloud provider, without the consuming team writing a single line of cloud-specific configuration.

#Step 1 — Define the Composite Resource Definition

The XRD establishes the OpenAPI schema exposed to developers. This is your platform’s public contract, analogous to a Terraform module’s variables.tf, but enforced by the Kubernetes API server’s validation webhook rather than a plan-time check.

1apiVersion: apiextensions.crossplane.io/v1
2kind: CompositeResourceDefinition
3metadata:
4  name: xpostgresqlinstances.platform.kby.io
5spec:
6  group: platform.kby.io
7  names:
8    kind: XPostgreSQLInstance
9    plural: xpostgresqlinstances
10  claimNames:
11    kind: PostgreSQLInstance
12    plural: postgresqlinstances
13  versions:
14    - name: v1alpha1
15      served: true
16      referenceable: true
17      schema:
18        openAPIV3Schema:
19          type: object
20          properties:
21            spec:
22              type: object
23              properties:
24                parameters:
25                  type: object
26                  properties:
27                    storageGB:
28                      type: integer
29                    region:
30                      type: string
31                    provider:
32                      type: string
33                      enum: ["aws", "azure"]
34                  required: ["storageGB", "region", "provider"]

#Step 2 — Author the Composition

The Composition maps the abstract schema to a concrete provider resource using patch-and-transform logic. This is the layer where multi-cloud branching actually happens — the developer’s claim never specifies AWS-specific fields like instanceClass directly.

1apiVersion: apiextensions.crossplane.io/v1
2kind: Composition
3metadata:
4  name: postgresql-aws
5  labels:
6    provider: aws
7spec:
8  compositeTypeRef:
9    apiVersion: platform.kby.io/v1alpha1
10    kind: XPostgreSQLInstance
11  resources:
12    - name: rdsinstance
13      base:
14        apiVersion: rds.aws.upbound.io/v1beta1
15        kind: Instance
16        spec:
17          forProvider:
18            engine: postgres
19            instanceClass: db.t3.medium
20            allocatedStorage: 20
21            region: eu-west-1
22      patches:
23        - fromFieldPath: "spec.parameters.storageGB"
24          toFieldPath: "spec.forProvider.allocatedStorage"
25        - fromFieldPath: "spec.parameters.region"
26          toFieldPath: "spec.forProvider.region"

#Step 3 — The Equivalent in Terraform

For direct comparison, the same abstraction expressed as a Terraform module requires explicit provider aliasing and a conditional resource block, since Terraform lacks native runtime branching between providers within a single module invocation without count or for_each hacks.

1variable "provider_target" {
2  type    = string
3  validation {
4    condition     = contains(["aws", "azure"], var.provider_target)
5    error_message = "provider_target must be aws or azure."
6  }
7}
8
9resource "aws_db_instance" "this" {
10  count             = var.provider_target == "aws" ? 1 : 0
11  engine            = "postgres"
12  instance_class    = "db.t3.medium"
13  allocated_storage = var.storage_gb
14  region            = var.region
15}
16
17resource "azurerm_postgresql_flexible_server" "this" {
18  count               = var.provider_target == "azure" ? 1 : 0
19  sku_name            = "B_Standard_B1ms"
20  storage_mb          = var.storage_gb * 1024
21  location            = var.region
22}

Note the structural cost: Terraform’s conditional-count pattern produces awkward output referencing (aws_db_instance.this[0]), which pollutes every downstream module consuming this abstraction. Crossplane Compositions avoid this because provider selection happens at the Composition-matching layer (via label selectors on the Claim), not inside a single monolithic resource block.

#Failure Modes & Edge Cases

Both approaches degrade under specific, predictable conditions that platform engineers must design around before exposing either as a self-service golden path.

Crossplane Compositions

#Terraform Failure Modes

State lock contention is the most common production incident in high-throughput IDPs. When multiple pipeline runs target overlapping state paths, DynamoDB lock acquisition failures cascade into queued CI jobs, often surfacing as opaque Error acquiring the state lock messages. A secondary failure mode is partial apply — if terraform apply is interrupted mid-run (spot instance termination in a CI runner, for example), the state file can diverge from real infrastructure, requiring manual terraform import or state surgery to reconcile.

#Crossplane Failure Modes

Crossplane’s continuous reconciliation introduces a different hazard: reconciliation storms. If a Composition’s patch logic produces a value that a cloud provider rejects (an invalid instance type, for instance), the controller enters a tight requeue loop, generating exponential API calls against the cloud provider until backoff limits are hit — this has, in observed production clusters, triggered AWS API throttling across unrelated workloads sharing the same IAM role. Additionally, XRD schema migrations are non-trivial: changing a required field on a served CRD version without a conversion webhook breaks every existing Claim referencing the old schema, unlike Terraform where module version pinning isolates consumers naturally via the registry’s semantic versioning.

Provider version skew is a shared edge case. Both Terraform providers and Crossplane’s Upbound providers (provider-aws, provider-azure) release independently of the core engine, and pinning strategy matters: unpinned Crossplane providers can silently upgrade Managed Resource CRDs mid-cluster-lifecycle, whereas Terraform’s required_providers block in versions.tf enforces strict lockfile behaviour via .terraform.lock.hcl.

1apiVersion: pkg.crossplane.io/v1
2kind: Provider
3metadata:
4  name: provider-aws-rds
5spec:
6  package: xpkg.upbound.io/upbound/provider-aws-rds:v1.9.0
7  packagePullPolicy: IfNotPresent
8  revisionActivationPolicy: Automatic

Pin packagePullPolicy to IfNotPresent and explicitly version-lock the package tag — leaving this on Always in a production control plane is the equivalent of running terraform init without a lockfile on every pipeline execution.

#Scaling & Security Trade-offs

When platform teams evaluate Crossplane Compositions against Terraform modules for the IDP’s abstraction layer, the decision typically settles on these dimensions. Reference the official Crossplane Compositions documentation for the full reconciliation specification before committing to a control-plane architecture.

  • Blast radius: Terraform’s blast radius is bounded by state file scope — misconfiguration affects only resources within that state. Crossplane’s blast radius is bounded by RBAC and Composition scope; a misconfigured Composition can propagate errors across every Claim referencing it cluster-wide within one reconciliation cycle.
  • Credential exposure: Terraform typically injects cloud credentials as CI environment variables (short-lived OIDC federation is best practice, e.g. GitHub Actions to AWS via sts:AssumeRoleWithWebIdentity). Crossplane stores provider credentials as long-lived Kubernetes Secrets referenced by ProviderConfig, meaning cluster RBAC and etcd encryption-at-rest become critical control points.
  • Drift correction latency: Terraform drift is detected only at the next scheduled plan (often hourly or daily via cron-triggered pipelines). Crossplane Compositions correct drift within one reconciliation interval, typically under a minute, materially reducing configuration-drift-related incidents.
  • Multi-tenancy isolation: Terraform requires bespoke state segregation (per-workspace or per-account backends) to isolate tenants. Crossplane provides isolation natively through Kubernetes namespaces and Claim-scoped RBAC, reducing the custom tooling the platform team must maintain.
  • Operational dependency: Terraform’s failure domain is your CI runner and remote state backend — both independently recoverable. Crossplane’s failure domain is the Kubernetes control plane itself; an etcd outage takes down your entire infrastructure abstraction layer, not just the last deployment.
  • Learning curve and adoption: Application teams already familiar with kubectl workflows adopt Crossplane Claims faster than HCL syntax, but platform engineers must now operate a controller fleet (health checks, provider upgrades, XRD versioning) as a first-class production system rather than a CI job.

Neither model is strictly superior in the abstract — the correct choice depends on whether your organisation’s control plane maturity can absorb the operational overhead of running Crossplane as production infrastructure, or whether a CI-driven Terraform pipeline with strict OIDC federation and remote state locking better fits your existing architectural patterns. Platforms already running GitOps reconciliation for application workloads gain disproportionate leverage from Crossplane Compositions, since the same Argo CD sync waves, RBAC boundaries, and audit trails extend directly to infrastructure without a second toolchain. Organisations without a mature Kubernetes control plane, or those provisioning infrequently-changing foundational infrastructure (VPCs, IAM boundaries), typically retain better auditability and lower operational risk with versioned Terraform modules behind a strict CI gate.

Marcus Thorne

Written By

Marcus Thorne

Marcus Thorne is a pragmatic software architect focused on highly concurrent, distributed transactional systems. With a robust background in designing eventual consistency models and event-driven microservices in Rust and Go, he has successfully transitioned monolithic architectures to event-sourced paradigms at scale. His technical philosophy prioritises mechanical sympathy, performance profiling, and rigorous domain-driven design.