{"schemaVersion":1,"id":"https://lucioduran.com/blog/crossplane-infrastructure-composition-kubernetes","url":"https://lucioduran.com/blog/crossplane-infrastructure-composition-kubernetes","title":"Crossplane: Infrastructure Composition on Kubernetes, Replacing Terraform with GitOps-Native IaC","description":"A practitioner's guide to Crossplane, infrastructure composition on Kubernetes using XRDs, compositions, and provider-aws. How we replaced Terraform with a GitOps-native IaC approach and what we learned in the process.","language":"en","datePublished":"2025-11-17","dateModified":null,"contentFormat":"text/markdown","content":"DevOps\n\n# Crossplane: Infrastructure Composition on Kubernetes, Replacing Terraform with GitOps-Native IaC\n\n2025-11-177 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/crossplane-infrastructure-composition-kubernetes>)\n\n## What Crossplane Actually Is\n\n[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.\n\nThe key abstractions:\n\n1. **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.\n2. **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.\n3. **Claims**, Namespace-scoped requests for composite resources. Application teams create claims; platform teams define what claims provision.\n\n## Building a Platform API with XRDs and Compositions\n\nHere'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.\n\n### The XRD (API Definition)\n\n```yaml\napiVersion: apiextensions.crossplane.io/v1\nkind: CompositeResourceDefinition\nmetadata:\n name: xdatabases.platform.example.com\nspec:\n group: platform.example.com\n names:\n kind: XDatabase\n plural: xdatabases\n claimNames:\n kind: Database\n plural: databases\n versions:\n - name: v1alpha1\n served: true\n referenceable: true\n schema:\n openAPIV3Schema:\n type: object\n properties:\n spec:\n type: object\n properties:\n engine:\n  type: string\n  enum: [\"postgres\", \"mysql\"]\n  description: \"Database engine\"\n engineVersion:\n  type: string\n  description: \"Engine version (e.g., '15.4' for postgres)\"\n size:\n  type: string\n  enum: [\"small\", \"medium\", \"large\"]\n  description: \"T-shirt size for the database instance\"\n region:\n  type: string\n  default: \"us-east-1\"\n  description: \"AWS region\"\n backupRetentionDays:\n  type: integer\n  default: 7\n  minimum: 1\n  maximum: 35\n required:\n - engine\n - engineVersion\n - size\n status:\n type: object\n properties:\n endpoint:\n  type: string\n port:\n  type: integer\n connectionSecretName:\n  type: string\n```\n\nThat's our platform API. Five fields for the app team. No VPC IDs, no security group rules, no IAM policies.\n\n### The Composition (Implementation)\n\n```yaml\napiVersion: apiextensions.crossplane.io/v1\nkind: Composition\nmetadata:\n name: xdatabase-aws-postgres\n labels:\n provider: aws\n engine: postgres\nspec:\n compositeTypeRef:\n apiVersion: platform.example.com/v1alpha1\n kind: XDatabase\n mode: Pipeline\n pipeline:\n - step: patch-and-transform\n functionRef:\n name: function-patch-and-transform\n input:\n apiVersion: pt.fn.crossplane.io/v1beta1\n kind: Resources\n resources:\n - name: rds-instance\n base:\n apiVersion: rds.aws.upbound.io/v1beta2\n kind: Instance\n spec:\n forProvider:\n  engine: postgres\n  allocatedStorage: 20\n  publiclyAccessible: false\n  storageEncrypted: true\n  skipFinalSnapshot: false\n  autoMinorVersionUpgrade: true\n  dbSubnetGroupName: \"platform-db-subnet-group\"\n  vpcSecurityGroupIds:\n  - \"sg-0abc123def456\"\n deletionPolicy: Delete\n patches:\n - type: FromCompositeFieldPath\n fromFieldPath: spec.engineVersion\n toFieldPath: spec.forProvider.engineVersion\n - type: FromCompositeFieldPath\n fromFieldPath: spec.region\n toFieldPath: spec.forProvider.region\n - type: FromCompositeFieldPath\n fromFieldPath: spec.backupRetentionDays\n toFieldPath: spec.forProvider.backupRetentionPeriod\n # T-shirt size mapping\n - type: FromCompositeFieldPath\n fromFieldPath: spec.size\n toFieldPath: spec.forProvider.instanceClass\n transforms:\n  - type: map\n  map:\n  small: db.t4g.micro\n  medium: db.t4g.medium\n  large: db.r6g.large\n # Write connection details to status\n - type: ToCompositeFieldPath\n fromFieldPath: status.atProvider.endpoint\n toFieldPath: status.endpoint\n - type: ToCompositeFieldPath\n fromFieldPath: status.atProvider.port\n toFieldPath: status.port\n\n - name: parameter-group\n base:\n apiVersion: rds.aws.upbound.io/v1beta1\n kind: ParameterGroup\n spec:\n forProvider:\n  family: postgres15\n  parameter:\n  - name: log_min_duration_statement\n  value: \"1000\"\n  - name: shared_preload_libraries\n  value: \"pg_stat_statements\"\n deletionPolicy: Delete\n\n - name: connection-secret\n base:\n apiVersion: kubernetes.crossplane.io/v1alpha2\n kind: Object\n spec:\n forProvider:\n  manifest:\n  apiVersion: v1\n  kind: Secret\n  metadata:\n  namespace: \"\" # patched from claim namespace\n```\n\nWhen 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.\n\n### The Claim (What App Teams Write)\n\n```yaml\napiVersion: platform.example.com/v1alpha1\nkind: Database\nmetadata:\n name: orders-db\n namespace: orders-service\nspec:\n engine: postgres\n engineVersion: \"15.4\"\n size: medium\n backupRetentionDays: 14\n```\n\nThat's it. Five lines of meaningful configuration. The platform team controls everything else.\n\n## The Migration from Terraform\n\nWe didn't rip out Terraform overnight. The migration took four months:\n\n**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.\n\n```yaml\napiVersion: rds.aws.upbound.io/v1beta2\nkind: Instance\nmetadata:\n name: orders-db-imported\n annotations:\n crossplane.io/external-name: \"orders-db-prod\"\nspec:\n forProvider:\n region: us-east-1\n # ... matching existing configuration\n deletionPolicy: Orphan # CRITICAL during migration\n```\n\n**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.\n\n**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.\n\n**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.\n\n## What Went Wrong\n\n**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.\n\n**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.\n\n```yaml\n# On the ControllerConfig for the provider\nspec:\n args:\n - --poll-interval=5m\n - --max-reconcile-rate=50\n```\n\n**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.\n\n**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.\n\n## Performance Comparison\n\n| Metric | Terraform | Crossplane |\n| --- | --- | --- |\n| Time to provision RDS | 12-15 min | 12-15 min (same, it's AWS) |\n| Time for developer to request | 45 min (PR + plan + apply) | 3 min (PR + Argo CD sync) |\n| Drift detection | Manual (terraform plan) | Continuous (every poll interval) |\n| State storage | S3 + DynamoDB lock | etcd (Kubernetes) |\n| Rollback | terraform apply with old state | git revert + Argo CD sync |\n\nThe 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.\n\n## Would I Do It Again?\n\nYes, but with caveats:\n\n1. **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.\n2. **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.\n3. **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.\n4. **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.\n\nThe 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.\n\n## Frequently asked questions\n\n### Should I replace Terraform with Crossplane?\n\nOnly 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.\n\n### How does Crossplane handle state compared to Terraform?\n\nCrossplane 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.\n\n### What are XRDs and Compositions in Crossplane?\n\nXRDs (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.\n\n## Tips\n\n1. **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.\n2. **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 `.\n3. **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.\n4. **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.\n\n- crossplane\n- kubernetes\n- infrastructure-as-code\n- gitops\n- terraform\n- xrd\n- compositions\n- provider-aws\n\n[Back to Blog](<https://lucioduran.com/blog>)","headings":[{"level":1,"text":"Crossplane: Infrastructure Composition on Kubernetes, Replacing Terraform with GitOps-Native IaC","url":"https://lucioduran.com/blog/crossplane-infrastructure-composition-kubernetes#title"},{"level":2,"text":"What Crossplane Actually Is","url":"https://lucioduran.com/blog/crossplane-infrastructure-composition-kubernetes#what-crossplane-actually-is"},{"level":2,"text":"Building a Platform API with XRDs and Compositions","url":"https://lucioduran.com/blog/crossplane-infrastructure-composition-kubernetes#building-a-platform-api-with-xrds-and-compositions"},{"level":3,"text":"The XRD (API Definition)","url":"https://lucioduran.com/blog/crossplane-infrastructure-composition-kubernetes#the-xrd-api-definition"},{"level":3,"text":"The Composition (Implementation)","url":"https://lucioduran.com/blog/crossplane-infrastructure-composition-kubernetes#the-composition-implementation"},{"level":3,"text":"The Claim (What App Teams Write)","url":"https://lucioduran.com/blog/crossplane-infrastructure-composition-kubernetes#the-claim-what-app-teams-write"},{"level":2,"text":"The Migration from Terraform","url":"https://lucioduran.com/blog/crossplane-infrastructure-composition-kubernetes#the-migration-from-terraform"},{"level":2,"text":"What Went Wrong","url":"https://lucioduran.com/blog/crossplane-infrastructure-composition-kubernetes#what-went-wrong"},{"level":2,"text":"Performance Comparison","url":"https://lucioduran.com/blog/crossplane-infrastructure-composition-kubernetes#performance-comparison"},{"level":2,"text":"Would I Do It Again?","url":"https://lucioduran.com/blog/crossplane-infrastructure-composition-kubernetes#would-i-do-it-again"},{"level":2,"text":"Frequently asked questions","url":"https://lucioduran.com/blog/crossplane-infrastructure-composition-kubernetes#questions"},{"level":3,"text":"Should I replace Terraform with Crossplane?","url":"https://lucioduran.com/blog/crossplane-infrastructure-composition-kubernetes#question-1"},{"level":3,"text":"How does Crossplane handle state compared to Terraform?","url":"https://lucioduran.com/blog/crossplane-infrastructure-composition-kubernetes#question-2"},{"level":3,"text":"What are XRDs and Compositions in Crossplane?","url":"https://lucioduran.com/blog/crossplane-infrastructure-composition-kubernetes#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/blog/crossplane-infrastructure-composition-kubernetes#article-tips"}],"sections":[{"heading":"Crossplane: Infrastructure Composition on Kubernetes, Replacing Terraform with GitOps-Native IaC","level":1,"url":"https://lucioduran.com/blog/crossplane-infrastructure-composition-kubernetes","content":"DevOps"},{"heading":"Crossplane: Infrastructure Composition on Kubernetes, Replacing Terraform with GitOps-Native IaC","level":1,"url":"https://lucioduran.com/blog/crossplane-infrastructure-composition-kubernetes#title","content":"2025-11-177 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/crossplane-infrastructure-composition-kubernetes>)"},{"heading":"What Crossplane Actually Is","level":2,"url":"https://lucioduran.com/blog/crossplane-infrastructure-composition-kubernetes#what-crossplane-actually-is","content":"[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.\n\nThe key abstractions:\n\n1. **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.\n2. **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.\n3. **Claims**, Namespace-scoped requests for composite resources. Application teams create claims; platform teams define what claims provision."},{"heading":"Building a Platform API with XRDs and Compositions","level":2,"url":"https://lucioduran.com/blog/crossplane-infrastructure-composition-kubernetes#building-a-platform-api-with-xrds-and-compositions","content":"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."},{"heading":"The XRD (API Definition)","level":3,"url":"https://lucioduran.com/blog/crossplane-infrastructure-composition-kubernetes#the-xrd-api-definition","content":"```yaml\napiVersion: apiextensions.crossplane.io/v1\nkind: CompositeResourceDefinition\nmetadata:\n name: xdatabases.platform.example.com\nspec:\n group: platform.example.com\n names:\n kind: XDatabase\n plural: xdatabases\n claimNames:\n kind: Database\n plural: databases\n versions:\n - name: v1alpha1\n served: true\n referenceable: true\n schema:\n openAPIV3Schema:\n type: object\n properties:\n spec:\n type: object\n properties:\n engine:\n  type: string\n  enum: [\"postgres\", \"mysql\"]\n  description: \"Database engine\"\n engineVersion:\n  type: string\n  description: \"Engine version (e.g., '15.4' for postgres)\"\n size:\n  type: string\n  enum: [\"small\", \"medium\", \"large\"]\n  description: \"T-shirt size for the database instance\"\n region:\n  type: string\n  default: \"us-east-1\"\n  description: \"AWS region\"\n backupRetentionDays:\n  type: integer\n  default: 7\n  minimum: 1\n  maximum: 35\n required:\n - engine\n - engineVersion\n - size\n status:\n type: object\n properties:\n endpoint:\n  type: string\n port:\n  type: integer\n connectionSecretName:\n  type: string\n```\n\nThat's our platform API. Five fields for the app team. No VPC IDs, no security group rules, no IAM policies."},{"heading":"The Composition (Implementation)","level":3,"url":"https://lucioduran.com/blog/crossplane-infrastructure-composition-kubernetes#the-composition-implementation","content":"```yaml\napiVersion: apiextensions.crossplane.io/v1\nkind: Composition\nmetadata:\n name: xdatabase-aws-postgres\n labels:\n provider: aws\n engine: postgres\nspec:\n compositeTypeRef:\n apiVersion: platform.example.com/v1alpha1\n kind: XDatabase\n mode: Pipeline\n pipeline:\n - step: patch-and-transform\n functionRef:\n name: function-patch-and-transform\n input:\n apiVersion: pt.fn.crossplane.io/v1beta1\n kind: Resources\n resources:\n - name: rds-instance\n base:\n apiVersion: rds.aws.upbound.io/v1beta2\n kind: Instance\n spec:\n forProvider:\n  engine: postgres\n  allocatedStorage: 20\n  publiclyAccessible: false\n  storageEncrypted: true\n  skipFinalSnapshot: false\n  autoMinorVersionUpgrade: true\n  dbSubnetGroupName: \"platform-db-subnet-group\"\n  vpcSecurityGroupIds:\n  - \"sg-0abc123def456\"\n deletionPolicy: Delete\n patches:\n - type: FromCompositeFieldPath\n fromFieldPath: spec.engineVersion\n toFieldPath: spec.forProvider.engineVersion\n - type: FromCompositeFieldPath\n fromFieldPath: spec.region\n toFieldPath: spec.forProvider.region\n - type: FromCompositeFieldPath\n fromFieldPath: spec.backupRetentionDays\n toFieldPath: spec.forProvider.backupRetentionPeriod\n # T-shirt size mapping\n - type: FromCompositeFieldPath\n fromFieldPath: spec.size\n toFieldPath: spec.forProvider.instanceClass\n transforms:\n  - type: map\n  map:\n  small: db.t4g.micro\n  medium: db.t4g.medium\n  large: db.r6g.large\n # Write connection details to status\n - type: ToCompositeFieldPath\n fromFieldPath: status.atProvider.endpoint\n toFieldPath: status.endpoint\n - type: ToCompositeFieldPath\n fromFieldPath: status.atProvider.port\n toFieldPath: status.port\n\n - name: parameter-group\n base:\n apiVersion: rds.aws.upbound.io/v1beta1\n kind: ParameterGroup\n spec:\n forProvider:\n  family: postgres15\n  parameter:\n  - name: log_min_duration_statement\n  value: \"1000\"\n  - name: shared_preload_libraries\n  value: \"pg_stat_statements\"\n deletionPolicy: Delete\n\n - name: connection-secret\n base:\n apiVersion: kubernetes.crossplane.io/v1alpha2\n kind: Object\n spec:\n forProvider:\n  manifest:\n  apiVersion: v1\n  kind: Secret\n  metadata:\n  namespace: \"\" # patched from claim namespace\n```\n\nWhen 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."},{"heading":"The Claim (What App Teams Write)","level":3,"url":"https://lucioduran.com/blog/crossplane-infrastructure-composition-kubernetes#the-claim-what-app-teams-write","content":"```yaml\napiVersion: platform.example.com/v1alpha1\nkind: Database\nmetadata:\n name: orders-db\n namespace: orders-service\nspec:\n engine: postgres\n engineVersion: \"15.4\"\n size: medium\n backupRetentionDays: 14\n```\n\nThat's it. Five lines of meaningful configuration. The platform team controls everything else."},{"heading":"The Migration from Terraform","level":2,"url":"https://lucioduran.com/blog/crossplane-infrastructure-composition-kubernetes#the-migration-from-terraform","content":"We didn't rip out Terraform overnight. The migration took four months:\n\n**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.\n\n```yaml\napiVersion: rds.aws.upbound.io/v1beta2\nkind: Instance\nmetadata:\n name: orders-db-imported\n annotations:\n crossplane.io/external-name: \"orders-db-prod\"\nspec:\n forProvider:\n region: us-east-1\n # ... matching existing configuration\n deletionPolicy: Orphan # CRITICAL during migration\n```\n\n**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.\n\n**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.\n\n**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."},{"heading":"What Went Wrong","level":2,"url":"https://lucioduran.com/blog/crossplane-infrastructure-composition-kubernetes#what-went-wrong","content":"**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.\n\n**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.\n\n```yaml\n# On the ControllerConfig for the provider\nspec:\n args:\n - --poll-interval=5m\n - --max-reconcile-rate=50\n```\n\n**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.\n\n**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."},{"heading":"Performance Comparison","level":2,"url":"https://lucioduran.com/blog/crossplane-infrastructure-composition-kubernetes#performance-comparison","content":"| Metric | Terraform | Crossplane |\n| --- | --- | --- |\n| Time to provision RDS | 12-15 min | 12-15 min (same, it's AWS) |\n| Time for developer to request | 45 min (PR + plan + apply) | 3 min (PR + Argo CD sync) |\n| Drift detection | Manual (terraform plan) | Continuous (every poll interval) |\n| State storage | S3 + DynamoDB lock | etcd (Kubernetes) |\n| Rollback | terraform apply with old state | git revert + Argo CD sync |\n\nThe 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."},{"heading":"Would I Do It Again?","level":2,"url":"https://lucioduran.com/blog/crossplane-infrastructure-composition-kubernetes#would-i-do-it-again","content":"Yes, but with caveats:\n\n1. **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.\n2. **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.\n3. **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.\n4. **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.\n\nThe 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."},{"heading":"Frequently asked questions","level":2,"url":"https://lucioduran.com/blog/crossplane-infrastructure-composition-kubernetes#questions","content":""},{"heading":"Should I replace Terraform with Crossplane?","level":3,"url":"https://lucioduran.com/blog/crossplane-infrastructure-composition-kubernetes#question-1","content":"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."},{"heading":"How does Crossplane handle state compared to Terraform?","level":3,"url":"https://lucioduran.com/blog/crossplane-infrastructure-composition-kubernetes#question-2","content":"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."},{"heading":"What are XRDs and Compositions in Crossplane?","level":3,"url":"https://lucioduran.com/blog/crossplane-infrastructure-composition-kubernetes#question-3","content":"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."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/blog/crossplane-infrastructure-composition-kubernetes#article-tips","content":"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.\n2. **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 `.\n3. **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.\n4. **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.\n\n- crossplane\n- kubernetes\n- infrastructure-as-code\n- gitops\n- terraform\n- xrd\n- compositions\n- provider-aws\n\n[Back to Blog](<https://lucioduran.com/blog>)"}],"links":[{"text":"Español","url":"https://lucioduran.com/es/blog/crossplane-infrastructure-composition-kubernetes"},{"text":"Crossplane","url":"https://www.crossplane.io/"},{"text":"Kubernetes","url":"https://kubernetes.io/"},{"text":"AWS","url":"https://aws.com/"},{"text":"Back to Blog","url":"https://lucioduran.com/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"BlogPosting","@id":"https://lucioduran.com/blog/crossplane-infrastructure-composition-kubernetes#article","mainEntityOfPage":{"@type":"WebPage","@id":"https://lucioduran.com/blog/crossplane-infrastructure-composition-kubernetes"},"headline":"Crossplane: Infrastructure Composition on Kubernetes, Replacing Terraform with GitOps-Native IaC","description":"A practitioner's guide to Crossplane, infrastructure composition on Kubernetes using XRDs, compositions, and provider-aws. How we replaced Terraform with a GitOps-native IaC approach and what we learned in the process.","datePublished":"2025-11-17","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"publisher":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"image":"https://lucioduran.com/favicons/og-default.png","url":"https://lucioduran.com/blog/crossplane-infrastructure-composition-kubernetes","inLanguage":"en","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"DevOps","keywords":"crossplane, kubernetes, infrastructure-as-code, gitops, terraform, xrd, compositions, provider-aws","wordCount":1225,"timeRequired":"PT7M","workTranslation":[{"@type":"BlogPosting","inLanguage":"es","url":"https://lucioduran.com/es/blog/crossplane-infrastructure-composition-kubernetes"}]},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/blog"},{"@type":"ListItem","position":3,"name":"Crossplane: Infrastructure Composition on Kubernetes, Replacing Terraform with GitOps-Native IaC","item":"https://lucioduran.com/blog/crossplane-infrastructure-composition-kubernetes"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Should I replace Terraform with Crossplane?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"How does Crossplane handle state compared to Terraform?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"What are XRDs and Compositions in Crossplane?","acceptedAnswer":{"@type":"Answer","text":"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."}}]}]}],"contentHash":"e658725209aed74146ba3778c3fcf851cb9a06ac2f609b2748bc2535048ef1f3","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/blog/crossplane-infrastructure-composition-kubernetes","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":65128,"contentBytes":13002}}
