What Crossplane Actually Is

Crossplane is a Kubernetes 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 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)

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)

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)

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.

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.

# 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.