<!-- Canonical: https://lucioduran.com/blog/crossplane-infrastructure-composition-kubernetes
Language: en
Content-SHA256: e658725209aed74146ba3778c3fcf851cb9a06ac2f609b2748bc2535048ef1f3
-->

DevOps

# Crossplane: Infrastructure Composition on Kubernetes, Replacing Terraform with GitOps-Native IaC

2025-11-177 min read

Lucio Durán
Engineering Manager & AI Solutions Architect

Also available in: [Español](<https://lucioduran.com/es/blog/crossplane-infrastructure-composition-kubernetes>)

## What Crossplane Actually Is

[Crossplane](<https://www.crossplane.io/>) is a [Kubernetes](<https://kubernetes.io/>) operator that lets you manage cloud infrastructure using Kubernetes custom resources. Instead of writing HCL files and running ` terraform apply `, you write YAML manifests and ` kubectl apply ` them. Crossplane's controllers reconcile the desired state against the cloud provider's actual state, continuously.

The key abstractions:

1. **Managed Resources (MRs)**, Direct 1:1 mappings to cloud resources. An ` RDSInstance ` MR provisions an [AWS](<https://aws.com/>) RDS instance. These are low-level and provider-specific.
2. **Composite Resources (XRs)**, Your custom platform API. You define the schema (via XRDs) and the implementation (via Compositions). An ` XDatabase ` XR might create an RDS instance, a security group, a parameter group, and DNS records, all from a single manifest.
3. **Claims**, Namespace-scoped requests for composite resources. Application teams create claims; platform teams define what claims provision.

## Building a Platform API with XRDs and Compositions

Here's the real example from our platform. Application teams need databases. They shouldn't need to know about VPC subnets, security groups, parameter groups, or IAM roles. They need: a database name, an engine, a size, and a region.

### The XRD (API Definition)

```yaml
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
 plural: databases
 versions:
 - name: v1alpha1
 served: true
 referenceable: true
 schema:
 openAPIV3Schema:
 type: object
 properties:
 spec:
 type: object
 properties:
 engine:
  type: string
  enum: ["postgres", "mysql"]
  description: "Database engine"
 engineVersion:
  type: string
  description: "Engine version (e.g., '15.4' for postgres)"
 size:
  type: string
  enum: ["small", "medium", "large"]
  description: "T-shirt size for the database instance"
 region:
  type: string
  default: "us-east-1"
  description: "AWS region"
 backupRetentionDays:
  type: integer
  default: 7
  minimum: 1
  maximum: 35
 required:
 - engine
 - engineVersion
 - size
 status:
 type: object
 properties:
 endpoint:
  type: string
 port:
  type: integer
 connectionSecretName:
  type: string
```

That's our platform API. Five fields for the app team. No VPC IDs, no security group rules, no IAM policies.

### The Composition (Implementation)

```yaml
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
 name: xdatabase-aws-postgres
 labels:
 provider: aws
 engine: postgres
spec:
 compositeTypeRef:
 apiVersion: platform.example.com/v1alpha1
 kind: XDatabase
 mode: Pipeline
 pipeline:
 - step: patch-and-transform
 functionRef:
 name: function-patch-and-transform
 input:
 apiVersion: pt.fn.crossplane.io/v1beta1
 kind: Resources
 resources:
 - name: rds-instance
 base:
 apiVersion: rds.aws.upbound.io/v1beta2
 kind: Instance
 spec:
 forProvider:
  engine: postgres
  allocatedStorage: 20
  publiclyAccessible: false
  storageEncrypted: true
  skipFinalSnapshot: false
  autoMinorVersionUpgrade: true
  dbSubnetGroupName: "platform-db-subnet-group"
  vpcSecurityGroupIds:
  - "sg-0abc123def456"
 deletionPolicy: Delete
 patches:
 - type: FromCompositeFieldPath
 fromFieldPath: spec.engineVersion
 toFieldPath: spec.forProvider.engineVersion
 - type: FromCompositeFieldPath
 fromFieldPath: spec.region
 toFieldPath: spec.forProvider.region
 - type: FromCompositeFieldPath
 fromFieldPath: spec.backupRetentionDays
 toFieldPath: spec.forProvider.backupRetentionPeriod
 # T-shirt size mapping
 - type: FromCompositeFieldPath
 fromFieldPath: spec.size
 toFieldPath: spec.forProvider.instanceClass
 transforms:
  - type: map
  map:
  small: db.t4g.micro
  medium: db.t4g.medium
  large: db.r6g.large
 # Write connection details to status
 - type: ToCompositeFieldPath
 fromFieldPath: status.atProvider.endpoint
 toFieldPath: status.endpoint
 - type: ToCompositeFieldPath
 fromFieldPath: status.atProvider.port
 toFieldPath: status.port

 - name: parameter-group
 base:
 apiVersion: rds.aws.upbound.io/v1beta1
 kind: ParameterGroup
 spec:
 forProvider:
  family: postgres15
  parameter:
  - name: log_min_duration_statement
  value: "1000"
  - name: shared_preload_libraries
  value: "pg_stat_statements"
 deletionPolicy: Delete

 - name: connection-secret
 base:
 apiVersion: kubernetes.crossplane.io/v1alpha2
 kind: Object
 spec:
 forProvider:
  manifest:
  apiVersion: v1
  kind: Secret
  metadata:
  namespace: "" # patched from claim namespace
```

When an app team creates a ` Database ` claim, this composition creates an RDS instance with our standard security configuration, a parameter group with our standard settings, and a Kubernetes secret with the connection details, all automatically.

### The Claim (What App Teams Write)

```yaml
apiVersion: platform.example.com/v1alpha1
kind: Database
metadata:
 name: orders-db
 namespace: orders-service
spec:
 engine: postgres
 engineVersion: "15.4"
 size: medium
 backupRetentionDays: 14
```

That's it. Five lines of meaningful configuration. The platform team controls everything else.

## The Migration from Terraform

We didn't rip out Terraform overnight. The migration took four months:

**Month 1: Import existing resources.** Crossplane can adopt existing cloud resources. For each Terraform-managed resource, we created a corresponding Crossplane MR with the cloud resource's ID in the ` spec.forProvider ` and ` crossplane.io/external-name ` annotation. With ` deletionPolicy: Orphan `, Crossplane observed but didn't modify or delete the resources.

```yaml
apiVersion: rds.aws.upbound.io/v1beta2
kind: Instance
metadata:
 name: orders-db-imported
 annotations:
 crossplane.io/external-name: "orders-db-prod"
spec:
 forProvider:
 region: us-east-1
 # ... matching existing configuration
 deletionPolicy: Orphan # CRITICAL during migration
```

**Month 2: Build XRDs and Compositions.** We designed the platform API (the XRDs) with our app teams, focusing on what they actually needed to configure versus what should be platform defaults.

**Month 3: Migrate app-by-app.** Each team replaced their Terraform references with Crossplane claims. Because both Crossplane and Terraform were pointing at the same resources (with Crossplane in Orphan mode), this was safe.

**Month 4: Decommission Terraform.** Once all resources were managed by Crossplane, we switched ` deletionPolicy ` to ` Delete `, removed the Terraform state, and archived the Terraform repos.

## What Went Wrong

**1. Provider maturity.** The AWS provider (provider-upbound-aws) covers ~85% of AWS services. We hit gaps in CloudFront distributions and Cognito user pools. For those, we used Crossplane's Terraform provider (` provider-terraform `) as a bridge, it runs actual Terraform inside a Crossplane controller. Ugly but functional.

**2. Reconciliation storms.** Crossplane reconciles every resource every 60 seconds by default. With 2,400 managed resources, that's 2,400 AWS API calls per minute. We hit AWS API rate limits within the first week. The fix: increase the ` --poll-interval ` to 5 minutes for stable resources, keep 1 minute for actively changing resources.

```yaml
# On the ControllerConfig for the provider
spec:
 args:
 - --poll-interval=5m
 - --max-reconcile-rate=50
```

**3. Secret management.** Crossplane writes connection secrets as Kubernetes Secrets, which are base64-encoded, not encrypted. We had to add External Secrets Operator to sync Crossplane-generated secrets to AWS Secrets Manager, then have applications read from Secrets Manager instead. An extra layer we didn't anticipate.

**4. Debugging is harder.** When a Terraform apply fails, you get an error message and a plan diff. When a Crossplane reconciliation fails, the error is on a Kubernetes event on a managed resource that's nested inside a composite resource that was created by a claim. You need ` crossplane beta trace ` and ` kubectl describe ` through three layers of resources to find the actual error.

## Performance Comparison

| Metric | Terraform | Crossplane |
| --- | --- | --- |
| Time to provision RDS | 12-15 min | 12-15 min (same, it's AWS) |
| Time for developer to request | 45 min (PR + plan + apply) | 3 min (PR + Argo CD sync) |
| Drift detection | Manual (terraform plan) | Continuous (every poll interval) |
| State storage | S3 + DynamoDB lock | etcd (Kubernetes) |
| Rollback | terraform apply with old state | git revert + Argo CD sync |

The provisioning time is identical, Crossplane doesn't make AWS faster. The developer experience improvement is in the workflow: same PR process for app and infrastructure, automatic drift correction, and no separate CI/CD pipeline for infrastructure.

## Would I Do It Again?

Yes, but with caveats:

1. **Only if you're already on Kubernetes.** Crossplane without Kubernetes makes no sense. If your org isn't running Kubernetes, use Terraform, Pulumi, or CDK.
2. **Only if you want a platform API.** If you're a small team where everyone manages their own infra, Crossplane's abstraction layers are overhead. If you're a platform team serving 10+ application teams, the XRD/Composition model is genuinely powerful.
3. **Budget for the learning curve.** Crossplane's concepts (MRs, XRs, Claims, Compositions, Functions) take about two weeks for an experienced Kubernetes engineer to internalize. For someone new to Kubernetes, add another month.
4. **Keep Terraform as a fallback.** Use ` provider-terraform ` for any resource that the native Crossplane provider doesn't support well. Purism isn't worth production outages.

The Kubernetes ecosystem is moving toward treating infrastructure the same as applications, declarative manifests, GitOps reconciliation, and API-driven provisioning. Crossplane is the most mature tool in this space, and for organizations already invested in Kubernetes, it's the natural next step. Just go in with eyes open about the operational complexity you're taking on.

## Frequently asked questions

### Should I replace Terraform with Crossplane?

Only if you're already running Kubernetes and want your infrastructure to be managed through the same GitOps workflow as your applications. Crossplane adds operational complexity (another controller to run, CRDs to manage) but provides continuous reconciliation, drift detection, and a Kubernetes-native developer experience. If you're not on Kubernetes, stick with Terraform.

### How does Crossplane handle state compared to Terraform?

Crossplane stores state as Kubernetes resources (custom resources in etcd), not in a separate state file. This means state is managed by Kubernetes' built-in etcd storage, replicated across control plane nodes, and backed up by your existing Kubernetes backup strategy. There's no state locking problem because Kubernetes handles concurrent access to resources.

### What are XRDs and Compositions in Crossplane?

XRDs (Composite Resource Definitions) define a custom API, like a CRD schema for your platform. Compositions define how that API is implemented, which cloud resources to create when someone creates an instance of your XRD. Together, they let you build a platform API that abstracts cloud complexity while giving platform engineers full control over what gets provisioned.

## Tips

1. **LuchoTip:** Always set ` deletionPolicy: Orphan ` on your managed resources during initial migration from Terraform. This prevents Crossplane from deleting your actual cloud resources if you accidentally delete the Kubernetes object. Switch to ` Delete ` only after you've validated the import.
2. **LuchoTip:** Use Crossplane's ` Usage ` resource (alpha) to declare dependencies between resources. Without it, deleting a VPC claim will attempt to delete the VPC before its subnets, causing the delete to hang. Usage enforces ordering just like Terraform's ` depends_on `.
3. **LuchoTip:** Your XRD schemas should be opinionated and narrow. Don't expose every AWS parameter, expose only what your platform teams need. A good XRD for an RDS instance has 5-8 fields, not 50. You're building a platform API, not wrapping the cloud API.
4. **LuchoTip:** Run ` crossplane beta trace ` on your composite resources when debugging. It shows the entire resource tree, which managed resources belong to which composition, what their status is, and where in the reconciliation loop they're stuck.

- crossplane
- kubernetes
- infrastructure-as-code
- gitops
- terraform
- xrd
- compositions
- provider-aws

[Back to Blog](<https://lucioduran.com/blog>)
