Crossplane lets you provision and manage cloud resources buckets, databases, queues directly from Kubernetes, in YAML, as part of your GitOps workflow. It’s powerful. But that power cuts both ways: Crossplane can also delete those resources if you’re not careful.

A misplaced kubectl delete, a merge on the wrong branch, an uncontrolled provider upgrade and a production database is gone.
This article covers the critical configuration points you need to get right before running Crossplane in production: provider versioning, deletionPolicy, and managementPolicies.
If you’re new to Crossplane and concepts like XRD, Composition, and Claims aren’t familiar yet, I recommend reading this introductory article first.
A Two-Layer Architecture: the Right Tool for the Right Layer
Before diving into configuration details, let’s set the context.
A cloud infrastructure naturally breaks down into two layers with very different lifecycles:
The foundation layer base infrastructure
The Kubernetes cluster itself (your Crossplane control-plane), the VPC network, basic IAM service accounts. These resources are stable, rarely changed, and managed by the platform team. They’re ideally provisioned with a traditional IaC tool: Terraform, Pulumi, OpenTofu, or similar.
The application layer team resources
GCS buckets, Cloud SQL instances, Redis, Artifact Registry, Gateway API routes, queues… These resources are more numerous, tied to application lifecycles, and can be managed directly by development teams via Crossplane Claims. This is where Crossplane delivers the most value.
┌─────────────────────────────────────────────────┐
│ Platform Team │
│ │
│ Terraform / Pulumi / OpenTofu │
│ ├── Cluster GKE (control-plane Crossplane) │
│ ├── VPC, subnets │
│ └── Base IAM │
└─────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ Platform Team + Dev Teams │
│ │
│ Crossplane (in the cluster) │
│ ├── Cloud SQL (PostgreSQL, MySQL) │
│ ├── GCS Buckets │
│ ├── Redis (Memorystore) │
│ ├── Artifact Registry │
│ └── Gateway API routes │
└─────────────────────────────────────────────────┘
This separation isn’t dogma it’s pragmatism. Traditional IaC tools shine for stable, shared infrastructure. Crossplane shines for the large number of application resources that live close to team workflows.
Provider Versioning: Don’t Let Crossplane Update Itself
A Crossplane Provider is itself a declarative resource in the cluster. Without a pinned version, Crossplane can automatically upgrade the provider to the latest available version.
# Avoid this Crossplane will pick the most recent version
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
name: provider-gcp-sql
spec:
package: xpkg.upbound.io/upbound/provider-gcp-sql:v1
# Recommended explicitly pinned version
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
name: provider-gcp-sql
spec:
package: xpkg.upbound.io/upbound/provider-gcp-sql:v1.8.0
This might seem minor. It isn’t.
When an Upgrade Goes Wrong: CRD Stuck in Terminating
Here’s a real scenario. You have an unversioned provider. Crossplane decides to upgrade it. The new version introduces a breaking CRD change for example, serviceaccountkeys.iam.gcp.upbound.io is restructured.
Crossplane tries to delete the old CRD to install the new one. But the CRD can’t be deleted as long as Managed Resources that depend on it still exist in the cluster. It gets stuck in a Terminating state.
$ kubectl get crd serviceaccountkeys.iam.gcp.upbound.io
NAME CREATED AT
serviceaccountkeys.iam.gcp.upbound.io 2024-01-15T10:23:00Z
$ kubectl get crd serviceaccountkeys.iam.gcp.upbound.io -o jsonpath='{.metadata.deletionTimestamp}'
2026-03-28T14:32:00Z # ← has been deleting for hours
To unblock the situation, you’d need to delete the ServiceAccountKey resources that depend on that CRD. Except in this particular case, the ServiceAccountKey is often the resource Crossplane itself uses to authenticate with GCP via the ProviderConfig. Deleting it cuts Crossplane’s access to the cloud paralyzing all managed resources.
This is exactly the same pattern as with deletionPolicy: Delete (more on that shortly): a maintenance action that triggers an unwanted cascade of deletions.
The rule is simple: always pin an explicit patch version for your providers. Update them manually, in a controlled way, testing on a non-critical environment first.

deletionPolicy: the Parameter That Can Delete Your Production Database
deletionPolicy controls what happens to the actual cloud resource when the Crossplane resource is deleted.
Delete(default) the cloud resource is deleted along with the Crossplane resourceOrphanthe cloud resource is kept, Crossplane simply stops managing it
The default is Delete. That makes sense for a development tool where you want automatic cleanup. It’s dangerous in production.
The Risky Scenarios
A helm uninstall of your application chart that bundles Crossplane Claims. A slightly too hasty kubectl delete namespace. A GitOps deletion on the wrong branch. In all these cases, with deletionPolicy: Delete, the cloud resources go with them.
Recommendation: Orphan by Default, Configurable in the Composition
The best practice is to set deletionPolicy: Orphan as the default in your Compositions, while making it configurable via a Claim parameter for cases where an actual deletion is intended.
# Managed Resource with explicit Orphan
apiVersion: sql.gcp.upbound.io/v1beta1
kind: DatabaseInstance
metadata:
name: my-postgres
spec:
deletionPolicy: Orphan
forProvider:
databaseVersion: POSTGRES_15
region: europe-west1
settings:
- tier: db-f1-micro
In a Composition, you can expose this parameter and patch it onto the Managed Resources:
# Composition excerpt patching deletionPolicy from the Claim
patches:
- type: FromCompositeFieldPath
fromFieldPath: spec.parameters.deletionPolicy
toFieldPath: spec.deletionPolicy
# XRD defining deletionPolicy as a parameter with a default value
# Simplified excerpt for illustration (the full schema goes inside spec.versions[].schema.openAPIV3Schema)
spec:
parameters:
deletionPolicy:
type: string
enum: [Delete, Orphan]
default: Orphan
# Claim on the developer side simple and explicit
apiVersion: platform.example.com/v1alpha1
kind: PostgresInstance
metadata:
name: my-app-db
spec:
parameters:
size: small
region: europe-west1
# deletionPolicy: Orphan ← default value, no need to specify it
This pattern gives developers a simple interface while protecting cloud resources by default. If an actual deletion is needed (cleaning up a dev environment, for example), they can explicitly pass deletionPolicy: Delete in their Claim.

managementPolicies: Defining What Crossplane Is Allowed to Do
managementPolicies controls the operations Crossplane can perform on a cloud resource. By default, the value is ["*"] Crossplane has full rights: create, update, delete, and retrieve values initialized by the cloud provider.
Available policies:
| Policy | Description |
|---|---|
Observe | Crossplane observes an existing cloud resource (identified via external-name) but does not modify it |
Create | Crossplane can create the resource |
Update | Crossplane can update the resource |
Delete | Crossplane can delete the resource |
LateInitialize | Crossplane retrieves values initialized by GCP and writes them into the spec |
* | All policies (default) |
Importing Existing Resources
If you want to import an already existing cloud resource into Crossplane (a database created manually, for instance), start with Observe only. This lets you validate that Crossplane finds the resource and reconciles it correctly, with no risk of accidental modification or deletion.
# Importing an existing resource observation only at first
apiVersion: sql.gcp.upbound.io/v1beta1
kind: DatabaseInstance
metadata:
name: existing-prod-db
annotations:
crossplane.io/external-name: my-existing-instance-name # actual GCP name
spec:
managementPolicies:
- Observe
forProvider:
databaseVersion: POSTGRES_15
region: europe-west1
settings:
- tier: db-custom-2-7680
Once you’ve confirmed the resource is being observed correctly (status Synced: True, Ready: True), you can move to full management by removing Observe and adding the other policies.

LateInitialize and GitOps Diffs
LateInitialize deserves special attention. When enabled, Crossplane writes into the resource spec the values that GCP initialized on its end (default values, computed fields). This can generate constant diffs in your GitOps repo if those values diverge regularly the spec in Git no longer matches what Crossplane wrote into the cluster.
# Full management without LateInitialize to avoid noisy GitOps diffs
spec:
managementPolicies:
- Create
- Update
- Delete
Be explicit about which policies you grant, especially when importing or migrating existing resources.
Wrapping Up
Crossplane is a solid tool for managing application-layer cloud resources from Kubernetes. But like any tool that writes and deletes in your real infrastructure, it requires thoughtful configuration.
Key takeaways:
- Separate the layers: traditional IaC for base infrastructure, Crossplane for application resources.
- Pin your provider versions: automatic upgrades can block your CRDs and, in the worst case, cut Crossplane’s authentication.
- Use
deletionPolicy: Orphanby default: make it configurable in your Compositions to stay flexible without exposing production to accidental deletions. - Be explicit about
managementPolicies: especially when importing existing resources start withObserve, validate, then open up permissions progressively.

These four rules don’t cover every case, but they protect against the most common and most painful incidents.
If you haven’t set up your Compositions and XRD yet, the introductory article linked at the top is a good starting point.