Skip to content

Crossplane Footguns

Mistakes that orphan cloud resources, create infinite reconcile loops, or delete production infrastructure.


1. Deleting a Managed Resource Destroys the Cloud Resource

You delete a kubectl delete rdsinstance prod-db because you want to remove it from Crossplane management — not delete the actual RDS instance. Crossplane's default deletion policy is Delete, which calls the cloud provider API to destroy the actual resource. Your production database is gone.

Fix: Before deleting any managed resource, set the deletion policy to Orphan: kubectl patch rdsinstance prod-db --type merge -p '{"spec":{"deletionPolicy":"Orphan"}}'. This removes the Crossplane resource without touching the cloud resource. Set deletionPolicy: Orphan in your Compositions for critical resources as a safety default during initial adoption.

Default trap: Crossplane's default deletionPolicy is Delete, not Orphan. This is the opposite of Terraform's default behavior (where terraform state rm detaches without destroying). Teams migrating from Terraform assume kubectl delete = terraform state rm. It is not.


2. ProviderConfig Credentials Scoped Too Broadly

You create a ProviderConfig that uses an IAM role with AdministratorAccess. Crossplane provisions and manages cloud resources using this role. Any team member with kubectl apply access can now create any AWS resource in any region with full permissions — provisioning expensive GPU clusters, exfiltrating data to S3, or modifying security groups.

Fix: Scope ProviderConfig IAM roles to only the services and actions Crossplane needs. Use separate ProviderConfigs per team or environment with different IAM roles. Use resource-based conditions in IAM policies to restrict regions, resource naming prefixes, and instance sizes. Audit what's actually being provisioned with kubectl get managed regularly.


3. XRD Schema Changes That Are Not Backward Compatible

You publish a v1alpha1 XRD with a parameters.size field. Teams build automation around it. You realize you need parameters.instanceSize instead, so you rename the field in v1alpha1. All existing claims now fail validation silently, or worse, the field is just ignored and the wrong default is used. Existing composite resources are left with no reconcile-able spec.

Fix: Treat XRD versions like API versions — never make breaking changes to a published version. Add a new version (v1beta1) alongside v1alpha1, write a conversion webhook or migration path, and deprecate the old version with a timeline. Use x-kubernetes-preserve-unknown-fields: true sparingly as a bridge during schema evolution.


4. Composition Patch Targeting Wrong Field Path Silently Does Nothing

You write a patch toFieldPath: spec.forProvider.dbInstanceClass but the correct path is spec.forProvider.instanceClass. The patch silently sets a field that doesn't exist, the real field gets its default value (or no value), and the cloud resource is provisioned with wrong specs. No error is thrown — Crossplane applies what it can and ignores unknown paths.

Fix: Use kubectl apply --dry-run=server to validate compositions before applying. After applying, always verify the managed resource's spec matches expectations: kubectl get rdsinstance -o json | jq '.spec.forProvider'. Use kubectl explain rdsinstance.spec.forProvider to verify exact field names. Test compositions against a staging environment with a single claim before rolling out.


5. No external-name Strategy for Resources That Already Exist

Your organization has existing AWS resources (VPCs, RDS instances, S3 buckets) and you deploy Crossplane to manage them. You create managed resources expecting Crossplane to "discover" the existing ones. Instead, Crossplane tries to create brand new resources. If naming conflicts occur in cloud APIs, you get errors. If not, you now have duplicate cloud resources and a surprise AWS bill.

Fix: For each existing resource you want to bring under Crossplane management, explicitly set the crossplane.io/external-name annotation to the cloud provider's resource identifier (e.g., RDS DB Identifier, S3 bucket name). Crossplane will then observe and manage the existing resource instead of creating a new one. Document this adoption process and test it in a non-production account first.


6. Pausing Reconciliation During Incidents Without Tracking Resumption

During an incident, you pause a managed resource to prevent Crossplane from reverting your manual hotfix: kubectl annotate rdsinstance prod-db crossplane.io/paused=true. The incident resolves. Weeks later, no one remembers the resource is paused. Someone updates the Crossplane manifest — nothing changes in AWS. The discrepancy causes a new incident, and it takes hours to realize the resource is paused.

Fix: Create a tracking mechanism for paused resources (a label, a dashboard query, a Slack reminder). Always set a resume reminder when pausing. Build monitoring: kubectl get managed -o json | jq '[.items[] | select(.metadata.annotations["crossplane.io/paused"] == "true") | .metadata.name]'. Alert on resources paused for more than 24 hours.


7. Composition Functions (Pipeline Mode) Failing Silently Without Events

You migrate from classic Composition to Composition Functions (Crossplane 1.14+). A function in the pipeline returns an error, but the composite resource shows Synced: True because the error is in the function's Result, not the main reconcile loop. Managed resources are not being created, but there's no obvious error surface.

Fix: When using Composition Functions, always check .status.conditions and .status.pipeline on the composite resource: kubectl get xpostgresqlinstance prod-db -o json | jq '.status'. Check function pod logs: kubectl logs -n crossplane-system deploy/function-patch-and-transform -f. Enable --debug mode on functions during development. Validate function inputs/outputs with crossplane render CLI before deploying.


8. Provider Package Version Pinned to latest

You install a provider with spec.package: xpkg.upbound.io/upbound/provider-aws:latest. A new provider version is released with breaking CRD schema changes. Crossplane auto-upgrades the provider, the CRDs change, and existing managed resources' specs become invalid. Your reconcile loops start throwing errors across your entire platform simultaneously.

Fix: Always pin provider packages to a specific version: spec.package: xpkg.upbound.io/upbound/provider-aws-rds:v1.2.0. Use a package management process (Renovate, manual review) to upgrade provider versions deliberately. Test provider upgrades in a staging Crossplane installation before upgrading production. Check provider changelogs for breaking changes before upgrading.

Gotcha: Crossplane provider packages use OCI image tags. Unlike Helm charts, there is no lock file — latest is a mutable tag that changes with every release. A Crossplane pod restart can pull a different provider version than what was running 5 minutes ago.


9. Cross-Namespace Resource References Not Working as Expected

You define a Composition that references a ProviderConfig by name. Teams deploy claims in different namespaces expecting to use different ProviderConfigs. But managed resources (which are cluster-scoped) always use the ProviderConfig named in the Composition, not one from the claim's namespace. Your multi-tenant isolation breaks silently.

Fix: Understand that managed resources are always cluster-scoped. Use spec.providerConfigRef in the Composition with a FromCompositeFieldPath patch to allow claims to specify which ProviderConfig to use. Design multi-tenancy around separate Crossplane installations per tenant (separate namespaces for the control plane), separate ProviderConfig names passed via claim parameters, or Upbound Spaces for stronger isolation.


10. Ignoring Managed Resource Conditions Leads to Undetected Provisioning Failures

You apply a Claim and see it created. You assume the underlying cloud resources are provisioned. In reality, the managed resources exist in Kubernetes but are stuck with Ready: False due to an IAM permission error or a quota limit. Your application deploys successfully (it has a Redis connection string from Crossplane's connection secret), but connects to nothing because the actual Redis cluster was never created.

Fix: Build health checks into your delivery pipeline that verify managed resource conditions before marking a deployment complete. Use kubectl wait --for=condition=Ready managed/rdsinstance-prod-db --timeout=10m in your deployment pipeline. Alert on managed resources that remain Ready: False for more than 15 minutes. Don't trust that a Crossplane resource existing in Kubernetes means the cloud resource exists.

Debug clue: kubectl get managed -o wide shows both READY and SYNCED columns. SYNCED=True + READY=False means Crossplane is talking to the cloud API but the resource is still provisioning (or failing). SYNCED=False means the provider can't reach the cloud API at all — check ProviderConfig credentials and IAM permissions first.