TL;DR
Crossplane is a CNCF graduated project that turns your Kubernetes cluster into a universal control plane for cloud infrastructure. Instead of running Terraform pipelines or ClickOps in the AWS console, you declare an S3 bucket or an RDS instance as a Kubernetes manifest and let Crossplane reconcile the real AWS resource continuously. This tutorial covers the full workflow: installing Crossplane with Helm, configuring the AWS provider with IRSA, provisioning S3 and RDS as Managed Resources, building reusable Compositions, and exposing a self-service database API to developers via Claims. You will also learn when to keep Terraform and when Crossplane fits better.
| What you will learn | Time needed |
|---|---|
| Install Crossplane via Helm | 10 min |
Configure provider-aws with IRSA | 15 min |
| Provision an S3 bucket as a CRD | 10 min |
| Provision an RDS PostgreSQL instance | 15 min |
| Build a Composition and XRD | 30 min |
| Expose Claims to developers | 15 min |
| Integrate with Argo CD | 10 min |
Prerequisites: A running Kubernetes cluster (EKS recommended, but kind or k3s work for local testing), kubectl configured, Helm v3, an AWS account with IAM permissions, and the AWS CLI.
What Is Crossplane? Kubernetes-Native IaC vs Terraform
Crossplane, originally built by Upbound and donated to the CNCF, extends the Kubernetes API to manage external cloud resources. The core idea is simple: everything in Kubernetes is a resource with a desired state; Crossplane makes cloud services — S3 buckets, RDS databases, VPCs, IAM roles — first-class Kubernetes resources.
When you apply a Crossplane manifest, a controller running inside your cluster calls the appropriate cloud API, creates or updates the real resource, and then continuously reconciles to make sure drift never persists. This is identical to how a Deployment controller watches pods and replaces them if they crash.
How It Differs from Terraform
| Dimension | Crossplane | Terraform |
|---|---|---|
| State storage | Kubernetes etcd (the live object IS the state) | Remote backend (S3 + DynamoDB, Terraform Cloud) |
| Execution model | Continuous reconciliation loop (operator pattern) | One-shot apply triggered by CI/CD |
| Drift detection | Real-time, automatic correction | Only on the next plan/apply run |
| API surface | Kubernetes CRDs — standard kubectl, RBAC, GitOps | HCL files, Terraform CLI, workspaces |
| Self-service | Claims let developers request infra without knowing AWS | Requires a Terraform module call or portal |
| Composability | Compositions with patches and transforms | Modules with input variables |
| Secrets | Kubernetes Secrets (or ESO) | Terraform outputs, encrypted state |
| Maturity | CNCF graduated (2024), broad provider ecosystem | Industry standard, 10+ years, vast registry |
| Best for | Platform teams building internal developer platforms | Ops teams managing multi-cloud, complex dependencies |
Neither tool is universally better. Crossplane excels when you want GitOps-native infrastructure managed from the same control plane as your applications. Terraform excels for complex dependency graphs, modules with decades of community investment, and teams not yet on Kubernetes.
Why Crossplane in 2026
Several converging trends have pushed Crossplane to the top of the IaC conversation in 2026.
CNCF Graduated status (2024). Crossplane joined the select group of CNCF graduated projects alongside Kubernetes, Prometheus, Helm, and Argo CD. Graduated status means the project has demonstrated production stability, a healthy governance model, and broad vendor adoption. Platform teams at enterprises such as Deutsche Telekom, Grupo Boticário, and Autodesk now run Crossplane in production.
GitOps is the default. As Argo CD and Flux have become the standard way to deploy applications, infrastructure teams want the same model: a Git commit is the only way to change anything. Crossplane manifests live in Git just like any other Kubernetes YAML and flow through the same Argo CD pipeline.
No state file headaches. Every engineer who has run Terraform in a team has hit a locked state file, a corrupted backend, or an import nightmare. Crossplane eliminates this problem because etcd is the state. Multiple engineers can work concurrently without conflicts.
Internal Developer Platforms (IDPs). The platform engineering movement has organisations building self-service portals on top of Kubernetes. Crossplane's Claim mechanism gives developers a simple, opinionated API ("give me a database") while hiding all the AWS complexity underneath.
Provider ecosystem. The Upbound Marketplace now hosts providers for AWS, Azure, GCP, GitHub, Vault, Helm, Kubernetes itself, Datadog, and dozens more. The provider-aws alone covers over 900 AWS resource types.
Install Crossplane with Helm
Crossplane runs as a set of pods in your cluster. The recommended installation method is the official Helm chart.
# Add the Crossplane Helm repository
helm repo add crossplane-stable https://charts.crossplane.io/stable
helm repo update
# Install Crossplane into its own namespace
helm install crossplane \
crossplane-stable/crossplane \
--namespace crossplane-system \
--create-namespace \
--version 1.17.0 \
--set args='{"--enable-composition-revisions"}' \
--wait
Verify the installation:
kubectl get pods -n crossplane-system
# NAME READY STATUS RESTARTS
# crossplane-7d9f8b6c4-x2kpn 1/1 Running 0
# crossplane-rbac-manager-5b9f6d7c9-v8ltm 1/1 Running 0
kubectl get crds | grep crossplane
# compositeresourcedefinitions.apiextensions.crossplane.io
# compositions.apiextensions.crossplane.io
# providers.pkg.crossplane.io
# ...
Install the Crossplane CLI for debugging commands used later:
curl -sL "https://raw.githubusercontent.com/crossplane/crossplane/master/install.sh" | sh
sudo mv crossplane /usr/local/bin/
crossplane version
Install the AWS Provider
Crossplane providers are packaged as OCI images. Installing one creates the provider CRDs for a given cloud.
# provider-aws.yaml
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
name: provider-aws-s3
spec:
package: xpkg.upbound.io/upbound/provider-aws-s3:v1.14.0
---
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
name: provider-aws-rds
spec:
package: xpkg.upbound.io/upbound/provider-aws-rds:v1.14.0
---
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
name: provider-aws-ec2
spec:
package: xpkg.upbound.io/upbound/provider-aws-ec2:v1.14.0
kubectl apply -f provider-aws.yaml
# Wait for providers to become healthy
kubectl get providers
# NAME INSTALLED HEALTHY PACKAGE
# provider-aws-s3 True True xpkg.upbound.io/upbound/provider-aws-s3:v1.14.0
# provider-aws-rds True True xpkg.upbound.io/upbound/provider-aws-rds:v1.14.0
# provider-aws-ec2 True True xpkg.upbound.io/upbound/provider-aws-ec2:v1.14.0
The provider pods install new CRDs into your cluster. You can now kubectl get AWS resource types directly.
ProviderConfig: AWS Authentication with IRSA
On EKS, the recommended authentication method is IRSA (IAM Roles for Service Accounts). This is the most secure approach — no long-lived credentials in Kubernetes Secrets.
Step 1: Create an IAM Role for Crossplane
export CLUSTER_NAME=my-cluster
export AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
export OIDC_PROVIDER=$(aws eks describe-cluster --name $CLUSTER_NAME \
--query "cluster.identity.oidc.issuer" --output text | sed -e "s/^https:\/\///")
# Create the trust policy
cat > crossplane-trust-policy.json <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::${AWS_ACCOUNT_ID}:oidc-provider/${OIDC_PROVIDER}"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"${OIDC_PROVIDER}:sub": "system:serviceaccount:crossplane-system:provider-aws",
"${OIDC_PROVIDER}:aud": "sts.amazonaws.com"
}
}
}
]
}
EOF
aws iam create-role \
--role-name CrossplaneProviderAWS \
--assume-role-policy-document file://crossplane-trust-policy.json
# Attach the AdministratorAccess policy (scope this down for production)
aws iam attach-role-policy \
--role-name CrossplaneProviderAWS \
--policy-arn arn:aws:iam::aws:policy/AdministratorAccess
Step 2: Create the ProviderConfig
# providerconfig-aws.yaml
apiVersion: aws.upbound.io/v1beta1
kind: ProviderConfig
metadata:
name: default
spec:
credentials:
source: IRSA
kubectl apply -f providerconfig-aws.yaml
Alternative: Static Credentials (for local/kind clusters)
For non-EKS environments, you can use a Kubernetes Secret with AWS credentials. This is acceptable for development but should never be used in production.
kubectl create secret generic aws-credentials \
-n crossplane-system \
--from-literal=creds="[default]
aws_access_key_id=$(aws configure get aws_access_key_id)
aws_secret_access_key=$(aws configure get aws_secret_access_key)"
apiVersion: aws.upbound.io/v1beta1
kind: ProviderConfig
metadata:
name: default
spec:
credentials:
source: Secret
secretRef:
namespace: crossplane-system
name: aws-credentials
key: creds
Managed Resources: Provision an S3 Bucket as a Kubernetes CRD
A Managed Resource (MR) is the lowest-level Crossplane concept — a one-to-one mapping to an AWS resource.
# s3-bucket.yaml
apiVersion: s3.aws.upbound.io/v1beta1
kind: Bucket
metadata:
name: my-crossplane-bucket-2026
annotations:
crossplane.io/external-name: my-crossplane-bucket-2026
spec:
forProvider:
region: us-east-1
tags:
ManagedBy: crossplane
Environment: production
providerConfigRef:
name: default
kubectl apply -f s3-bucket.yaml
# Watch the bucket come online
kubectl get bucket my-crossplane-bucket-2026
# NAME READY SYNCED EXTERNAL-NAME AGE
# my-crossplane-bucket-2026 True True my-crossplane-bucket-2026 45s
# Confirm in AWS
aws s3 ls | grep my-crossplane-bucket-2026
The READY=True status means the bucket exists and matches the desired state. SYNCED=True means the last reconciliation succeeded. If you delete the bucket manually via the AWS console, Crossplane will re-create it within the next reconciliation interval (default: 10 minutes, configurable).
To add a bucket policy and versioning:
apiVersion: s3.aws.upbound.io/v1beta1
kind: BucketVersioning
metadata:
name: my-crossplane-bucket-versioning
spec:
forProvider:
region: us-east-1
bucketRef:
name: my-crossplane-bucket-2026
versioningConfiguration:
- status: Enabled
providerConfigRef:
name: default
Provision an RDS PostgreSQL Instance
RDS instances are composed of several sub-resources. Here is a complete example including a subnet group.
# rds-subnet-group.yaml
apiVersion: rds.aws.upbound.io/v1beta1
kind: SubnetGroup
metadata:
name: crossplane-db-subnet-group
spec:
forProvider:
region: us-east-1
description: Crossplane managed DB subnet group
subnetIds:
- subnet-0abc123def456789a
- subnet-0abc123def456789b
tags:
ManagedBy: crossplane
providerConfigRef:
name: default
# rds-instance.yaml
apiVersion: rds.aws.upbound.io/v1beta1
kind: Instance
metadata:
name: crossplane-postgres
spec:
forProvider:
region: us-east-1
dbInstanceClass: db.t3.micro
engine: postgres
engineVersion: "16.2"
allocatedStorage: 20
dbName: appdb
username: adminuser
passwordSecretRef:
namespace: default
name: rds-password
key: password
dbSubnetGroupNameRef:
name: crossplane-db-subnet-group
publiclyAccessible: false
multiAz: false
storageEncrypted: true
tags:
ManagedBy: crossplane
Environment: staging
providerConfigRef:
name: default
writeConnectionSecretsToRef:
namespace: default
name: crossplane-postgres-conn
Create the password secret first:
kubectl create secret generic rds-password \
--from-literal=password='SomeSecurePassword123!'
Apply the manifests and watch the instance provision (RDS typically takes 5–10 minutes):
kubectl apply -f rds-subnet-group.yaml
kubectl apply -f rds-instance.yaml
kubectl get instance crossplane-postgres
# NAME READY SYNCED EXTERNAL-NAME AGE
# crossplane-postgres False True crossplane-postgres 3m
# After provisioning completes
# NAME READY SYNCED EXTERNAL-NAME AGE
# crossplane-postgres True True crossplane-postgres 9m
# Connection details are written to the specified Secret
kubectl get secret crossplane-postgres-conn -o yaml
Crossplane writes the endpoint, port, username, and connection string into the crossplane-postgres-conn Secret automatically. Your application can mount this Secret as environment variables.
Compositions: Build Reusable Infrastructure Abstractions
Managed Resources give you raw AWS access. Compositions let you build higher-level, opinionated abstractions that hide complexity from consumers. A Composition is analogous to a Terraform module: it wires together multiple Managed Resources and exposes a curated API.
A Composition requires two pieces: a CompositeResourceDefinition (XRD) that defines the API schema, and a Composition that implements it.
CompositeResourceDefinition (XRD)
The XRD defines the shape of your custom resource — what fields users can set and what fields Crossplane will populate.
# xrd-database.yaml
apiVersion: apiextensions.crossplane.io/v1
kind: CompositeResourceDefinition
metadata:
name: xpostgresdatabases.platform.example.com
spec:
group: platform.example.com
names:
kind: XPostgresDatabase
plural: xpostgresdatabases
claimNames:
kind: PostgresDatabase
plural: postgresdatabases
versions:
- name: v1alpha1
served: true
referenceable: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
parameters:
type: object
required:
- storageGB
- instanceClass
properties:
storageGB:
type: integer
description: Storage size in GB (min 20, max 1000)
minimum: 20
maximum: 1000
instanceClass:
type: string
description: RDS instance class
enum:
- db.t3.micro
- db.t3.small
- db.t3.medium
- db.m6g.large
region:
type: string
description: AWS region
default: us-east-1
multiAz:
type: boolean
description: Enable Multi-AZ for high availability
default: false
required:
- parameters
status:
type: object
properties:
endpoint:
type: string
description: The RDS endpoint address
port:
type: integer
description: The RDS port
kubectl apply -f xrd-database.yaml
# Two new CRDs are now available in the cluster
kubectl get crds | grep platform.example.com
# xpostgresdatabases.platform.example.com
# postgresdatabases.platform.example.com
Composition with Patches and Transforms
The Composition maps the XRD fields to real AWS Managed Resource fields using patches and transforms.
# composition-database.yaml
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: postgres-aws-standard
labels:
provider: aws
db: postgres
spec:
compositeTypeRef:
apiVersion: platform.example.com/v1alpha1
kind: XPostgresDatabase
writeConnectionSecretsToNamespace: crossplane-system
resources:
- name: rds-instance
base:
apiVersion: rds.aws.upbound.io/v1beta1
kind: Instance
spec:
forProvider:
engine: postgres
engineVersion: "16.2"
dbName: appdb
username: adminuser
storageEncrypted: true
publiclyAccessible: false
skipFinalSnapshot: true
autoMinorVersionUpgrade: true
providerConfigRef:
name: default
writeConnectionSecretsToRef:
namespace: crossplane-system
patches:
# Map storageGB from the claim to allocatedStorage
- type: FromCompositeFieldPath
fromFieldPath: spec.parameters.storageGB
toFieldPath: spec.forProvider.allocatedStorage
# Map instanceClass
- type: FromCompositeFieldPath
fromFieldPath: spec.parameters.instanceClass
toFieldPath: spec.forProvider.dbInstanceClass
# Map region
- type: FromCompositeFieldPath
fromFieldPath: spec.parameters.region
toFieldPath: spec.forProvider.region
# Map multiAz
- type: FromCompositeFieldPath
fromFieldPath: spec.parameters.multiAz
toFieldPath: spec.forProvider.multiAz
# Build a unique name for the RDS instance using the XR name
- type: FromCompositeFieldPath
fromFieldPath: metadata.name
toFieldPath: spec.forProvider.tags.DatabaseName
# Write the connection secret name using the XR name
- type: FromCompositeFieldPath
fromFieldPath: metadata.name
toFieldPath: spec.writeConnectionSecretsToRef.name
transforms:
- type: string
string:
fmt: "%s-conn"
# Propagate the endpoint back to the XR status
- type: ToCompositeFieldPath
fromFieldPath: status.atProvider.endpoint
toFieldPath: status.endpoint
- type: ToCompositeFieldPath
fromFieldPath: status.atProvider.port
toFieldPath: status.port
connectionDetails:
- name: endpoint
fromFieldPath: status.atProvider.endpoint
- name: port
fromFieldPath: status.atProvider.port
- name: username
fromFieldPath: spec.forProvider.username
- name: rds-subnet-group
base:
apiVersion: rds.aws.upbound.io/v1beta1
kind: SubnetGroup
spec:
forProvider:
description: Managed by Crossplane Composition
subnetIds:
- subnet-0abc123def456789a
- subnet-0abc123def456789b
providerConfigRef:
name: default
patches:
- type: FromCompositeFieldPath
fromFieldPath: spec.parameters.region
toFieldPath: spec.forProvider.region
- type: FromCompositeFieldPath
fromFieldPath: metadata.name
toFieldPath: spec.forProvider.dbSubnetGroupName
transforms:
- type: string
string:
fmt: "%s-subnet-group"
kubectl apply -f composition-database.yaml
Composite Resources (XR) and Claims (XRC): Self-Service for Developers
With the XRD and Composition in place, two new API types exist in the cluster. Platform engineers use Composite Resources (XR) directly (cluster-scoped). Developers use Claims (XRC), which are namespace-scoped and act as a lightweight proxy to the XR.
This separation is deliberate: a developer in the team-alpha namespace claims a database without knowing anything about VPCs, subnet groups, encryption settings, or IAM. The platform team encodes all those opinions in the Composition.
Composite Resource (XR) — Platform Engineer Perspective
# xr-database.yaml
apiVersion: platform.example.com/v1alpha1
kind: XPostgresDatabase
metadata:
name: xdb-team-alpha
spec:
parameters:
storageGB: 50
instanceClass: db.t3.small
region: us-east-1
multiAz: false
compositionRef:
name: postgres-aws-standard
writeConnectionSecretToRef:
namespace: crossplane-system
name: xdb-team-alpha-conn
Claim (XRC) — Developer Perspective
# claim-database.yaml
apiVersion: platform.example.com/v1alpha1
kind: PostgresDatabase
metadata:
name: my-app-db
namespace: team-alpha
spec:
parameters:
storageGB: 20
instanceClass: db.t3.micro
region: us-east-1
multiAz: false
compositionRef:
name: postgres-aws-standard
writeConnectionSecretToRef:
name: my-app-db-conn
kubectl apply -f claim-database.yaml -n team-alpha
# Watch the claim and the underlying XR
kubectl get postgresdatabase -n team-alpha
# NAME READY CONNECTION-SECRET AGE
# my-app-db True my-app-db-conn 12m
# The connection Secret appears in the developer's namespace
kubectl get secret my-app-db-conn -n team-alpha
The developer's application can now consume the database connection like any Kubernetes Secret:
env:
- name: DB_HOST
valueFrom:
secretKeyRef:
name: my-app-db-conn
key: endpoint
- name: DB_PORT
valueFrom:
secretKeyRef:
name: my-app-db-conn
key: port
Real Example: Database-as-a-Service
Let's walk through the complete journey from platform setup to developer self-service.
Platform engineer workflow (done once):
- Install Crossplane and
provider-aws-rds. - Create
ProviderConfigwith IRSA credentials. - Apply the XRD (
xpostgresdatabases.platform.example.com). - Apply the Composition (
postgres-aws-standard) encoding company standards: encryption on, public access off, approved instance sizes only, tag enforcement. - Apply RBAC so developers can create
PostgresDatabaseClaims in their namespaces but cannot touch Managed Resources or XRs.
# rbac-developer.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: crossplane-database-claimer
rules:
- apiGroups:
- platform.example.com
resources:
- postgresdatabases
verbs:
- get
- list
- watch
- create
- update
- patch
- delete
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: team-alpha-database-claimer
namespace: team-alpha
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: crossplane-database-claimer
subjects:
- kind: Group
name: team-alpha-developers
apiGroup: rbac.authorization.k8s.io
Developer workflow (repeatable, self-service):
# Developer applies a single 10-line YAML
kubectl apply -f claim-database.yaml -n team-alpha
# 10 minutes later, the database is ready
kubectl get postgresdatabase my-app-db -n team-alpha
# NAME READY CONNECTION-SECRET AGE
# my-app-db True my-app-db-conn 10m
# Connection details are in the Secret — no AWS console access needed
kubectl get secret my-app-db-conn -n team-alpha -o jsonpath='{.data.endpoint}' | base64 -d
This is the core value proposition of Crossplane: the platform team ships a curated API, developers get infrastructure without tickets or waiting, and everything is auditable via Git history and Kubernetes events.
Crossplane vs Terraform: When to Choose Each
This is the most common question teams face when adopting Crossplane. The answer is not binary — many organisations run both.
Choose Crossplane when:
- Your platform is Kubernetes-native and your team already lives in
kubectl. - You want GitOps for infrastructure, using the same Argo CD pipeline that deploys apps.
- You are building an Internal Developer Platform where developers self-serve infrastructure via a curated API (Claims).
- You need continuous drift correction (Crossplane re-applies the desired state; Terraform only detects drift when you run a plan).
- You want infrastructure lifecycle tied to application lifecycle (delete the Claim, delete the AWS resource — no orphaned resources).
- Your team is comfortable with Kubernetes operators and CRDs.
Choose Terraform when:
- You manage infrastructure that spans many accounts and regions with complex dependency graphs.
- You rely on a large existing library of community Terraform modules (the Terraform Registry has decades of investment).
- Your team is not on Kubernetes, or you are managing resources that Crossplane does not yet cover.
- You need Terraform-specific features like
movedblocks,importblocks for adopting existing resources, or CDK for Terraform. - You have compliance requirements that mandate a specific state audit trail that your team already has tooling around.
Hybrid approach (common in 2026): Use Terraform to provision foundational resources (VPCs, EKS clusters, IAM roles) and Crossplane for application-level infrastructure provisioned on demand (databases, queues, object storage) with developer self-service via Claims.
Crossplane + Argo CD: GitOps-Driven Infrastructure
Crossplane and Argo CD are natural partners. Argo CD manages the Git-to-cluster sync; Crossplane manages the cluster-to-cloud sync. Together they form a two-tier GitOps pipeline.
Git Repository
│
│ Argo CD watches and syncs
▼
Kubernetes Cluster (Crossplane Manifests: XRD, Composition, ProviderConfig)
│
│ Crossplane reconciles
▼
AWS Cloud (RDS, S3, VPCs, IAM, ...)
Repository Structure
infra-gitops/
├── platform/
│ ├── crossplane/
│ │ ├── providers/
│ │ │ ├── provider-aws-s3.yaml
│ │ │ ├── provider-aws-rds.yaml
│ │ │ └── providerconfig.yaml
│ │ ├── compositions/
│ │ │ ├── xrd-database.yaml
│ │ │ └── composition-database.yaml
│ │ └── rbac/
│ │ └── rbac-developer.yaml
└── teams/
└── team-alpha/
└── claims/
└── my-app-db.yaml
Argo CD Application for Platform Infrastructure
# argocd-crossplane-platform.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: crossplane-platform
namespace: argocd
spec:
project: platform
source:
repoURL: https://github.com/myorg/infra-gitops
targetRevision: main
path: platform/crossplane
destination:
server: https://kubernetes.default.svc
namespace: crossplane-system
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
- ServerSideApply=true
The ServerSideApply=true sync option is important for Crossplane because CRD schemas are large and frequently hit annotation size limits with client-side apply.
Developer Claims via Argo CD
Each team can have an Argo CD Application that manages their Claims:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: team-alpha-infrastructure
namespace: argocd
spec:
project: team-alpha
source:
repoURL: https://github.com/myorg/infra-gitops
targetRevision: main
path: teams/team-alpha/claims
destination:
server: https://kubernetes.default.svc
namespace: team-alpha
syncPolicy:
automated:
prune: true
selfHeal: true
When a developer opens a pull request adding a new PostgresDatabase Claim, the platform review process replaces the Jira ticket. Approval and merge trigger Argo CD, which creates the Claim, which triggers Crossplane, which provisions RDS. The entire chain is auditable in Git.
Debugging: kubectl describe and crossplane beta trace
kubectl describe
The first debugging tool is always kubectl describe. Crossplane populates the Events and Status.Conditions fields with detailed error messages.
# Check the Claim
kubectl describe postgresdatabase my-app-db -n team-alpha
# Check the underlying XR (cluster-scoped)
kubectl describe xpostgresdatabase xdb-team-alpha
# Check the Managed Resource
kubectl describe instance crossplane-postgres
# Common conditions to look for:
# Type=Ready, Reason=Available — all good
# Type=Synced, Reason=ReconcileError — provider returned an error
# Type=Healthy, Reason=UnhealthyPackageRevision — provider image issue
crossplane beta trace
The crossplane beta trace command is the most powerful debugging tool. It shows the full resource tree from Claim down to Managed Resources with health status at each level.
crossplane beta trace postgresdatabase my-app-db -n team-alpha
# Example output:
# NAME SYNCED READY STATUS
# PostgresDatabase/my-app-db True False Waiting: Claim is waiting for composite to become Ready
# └─ XPostgresDatabase/my-app-db-abcde True False Waiting: Composite is waiting for managed resources
# ├─ Instance/my-app-db-abcde-rds False False ReconcileError: cannot create Instance: ...
# └─ SubnetGroup/my-app-db-abcde-subnet True True Available
Common Issues and Fixes
Provider not healthy:
kubectl describe provider provider-aws-rds
# Check Events for pull errors (image not found, registry auth issues)
ProviderConfig not found:
# Ensure the providerConfigRef.name in your MR matches an existing ProviderConfig
kubectl get providerconfig
IRSA not working:
# Check the provider pod's service account annotation
kubectl get sa -n crossplane-system
kubectl describe sa provider-aws -n crossplane-system
# The annotation iam.amazonaws.com/role-arn must be present
Composition not selected:
# The Composition must have a compositeTypeRef matching the XRD group/version/kind
# Check selector labels if using compositionSelector instead of compositionRef
kubectl get composition -o wide
FAQ
Q: Does Crossplane delete AWS resources when I delete a Manifest? Yes, by default. Crossplane resources have a deletionPolicy field. The default is Delete, which removes the AWS resource. Set deletionPolicy: Orphan to keep the AWS resource but stop managing it from Crossplane. This is useful during migrations.
Q: Can I import existing AWS resources into Crossplane? Yes. Set the crossplane.io/external-name annotation on a Managed Resource to the existing AWS resource ID (e.g., the RDS instance identifier). Crossplane will adopt it rather than creating a new one.
Q: How does Crossplane handle secrets and sensitive values? Crossplane uses Kubernetes Secrets for sensitive values. The passwordSecretRef field on an RDS Instance references a Secret for the master password. Connection details are written to a Secret via writeConnectionSecretsToRef. For additional security, combine with External Secrets Operator (ESO) to source secrets from AWS Secrets Manager.
Q: Can I use Crossplane without EKS (e.g., on a local kind cluster)? Yes. Use static credentials (Secret-based ProviderConfig) for local development. IRSA is EKS-specific; GKE and AKS have equivalent workload identity mechanisms that provider-aws also supports.
Q: What is the difference between a Composite Resource (XR) and a Claim (XRC)? An XR is cluster-scoped and used by platform engineers. A Claim is namespace-scoped and used by application developers. A Claim creates and binds to an XR automatically. This separation enables multi-tenancy: developers in different namespaces cannot see each other's Claims or the underlying XRs.
Q: How do I upgrade provider versions? Edit the spec.package version in the Provider manifest and re-apply. Crossplane will pull the new provider image and perform a rolling update of the provider pod. CRDs are updated automatically.
Q: Can Crossplane manage resources across multiple AWS accounts? Yes. Create multiple ProviderConfig objects, each with credentials for a different account. Reference the appropriate ProviderConfig in each Managed Resource or Composition patch.
Q: Is Crossplane production-ready in 2026? Yes. Crossplane reached CNCF graduation in 2024, which requires demonstrated production adoption at scale. Upbound (the commercial backer) offers a managed control plane product (Upbound Cloud) for organisations that want a hosted Crossplane. The provider-aws family is battle-tested across hundreds of resource types.
Sources
- Crossplane Official Documentation — Concepts, API reference, installation guides
- Upbound Marketplace — Provider registry including
provider-aws, Azure, GCP providers - CNCF Crossplane Project Page — Governance, graduation announcement, community links
- Crossplane GitHub Repository — Source code, releases, issue tracker
- provider-aws GitHub Repository — AWS provider source and CRD reference
- Crossplane Composition Documentation — Patch and transform reference
- AWS IRSA Documentation — IAM Roles for Service Accounts on EKS
- Argo CD + Crossplane Integration Guide — Official integration patterns and ServerSideApply configuration
- Crossplane Community Slack —
#crossplaneand#provider-awschannels for community support - Upbound Blog: Crossplane in Production — Case studies, best practices, and release notes