}

Crossplane AWS Tutorial 2026: Kubernetes-Native Infrastructure Provisioning

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 learnTime needed
Install Crossplane via Helm10 min
Configure provider-aws with IRSA15 min
Provision an S3 bucket as a CRD10 min
Provision an RDS PostgreSQL instance15 min
Build a Composition and XRD30 min
Expose Claims to developers15 min
Integrate with Argo CD10 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

DimensionCrossplaneTerraform
State storageKubernetes etcd (the live object IS the state)Remote backend (S3 + DynamoDB, Terraform Cloud)
Execution modelContinuous reconciliation loop (operator pattern)One-shot apply triggered by CI/CD
Drift detectionReal-time, automatic correctionOnly on the next plan/apply run
API surfaceKubernetes CRDs — standard kubectl, RBAC, GitOpsHCL files, Terraform CLI, workspaces
Self-serviceClaims let developers request infra without knowing AWSRequires a Terraform module call or portal
ComposabilityCompositions with patches and transformsModules with input variables
SecretsKubernetes Secrets (or ESO)Terraform outputs, encrypted state
MaturityCNCF graduated (2024), broad provider ecosystemIndustry standard, 10+ years, vast registry
Best forPlatform teams building internal developer platformsOps 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):

  1. Install Crossplane and provider-aws-rds.
  2. Create ProviderConfig with IRSA credentials.
  3. Apply the XRD (xpostgresdatabases.platform.example.com).
  4. Apply the Composition (postgres-aws-standard) encoding company standards: encryption on, public access off, approved instance sizes only, tag enforcement.
  5. Apply RBAC so developers can create PostgresDatabase Claims 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 moved blocks, import blocks 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

Leonardo Lazzaro

Software engineer and technical writer. 10+ years experience in DevOps, Python, and Linux systems.

More articles by Leonardo Lazzaro