Platform Engineering
14 min readMay 7, 2026Updated September 21, 2026

Crossplane: Cloud Infrastructure as Kubernetes Resources

CO
Coding Protocols Team
Platform Engineering
Crossplane: Cloud Infrastructure as Kubernetes Resources

Quick answer

Crossplane turns your Kubernetes cluster into a control plane for cloud resources. A developer applies a Claim (namespace-scoped, like a Kubernetes Deployment) and Crossplane provisions an RDS instance, S3 bucket, or IAM role via the cloud provider API — tracked as a Kubernetes resource with the same lifecycle as the app. This covers Crossplane installation on EKS, AWS provider setup with Pod Identity, Managed Resources for direct AWS provisioning, and Compositions that abstract over cloud APIs to give teams a platform-defined interface.

14 min read · Platform Engineering

Terraform and CloudFormation manage cloud infrastructure. Kubernetes manages application workloads. Crossplane bridges the two: it extends the Kubernetes API with custom resources that represent cloud infrastructure. When you apply a Crossplane RDSInstance resource, Crossplane calls the AWS API to provision the database and keeps the Kubernetes resource synchronized with the actual AWS state.

The practical consequence is that your GitOps workflow (Argo CD, Flux) manages infrastructure the same way it manages applications: a git commit provisions an RDS instance, a git revert deprovisions it. Crossplane doesn't replace Terraform — it replaces the need for a separate IaC toolchain when your team is already all-in on Kubernetes.


Crossplane Architecture

Crossplane runs as a Deployment in your cluster. When you apply a Crossplane resource (RDSInstance, S3Bucket), the Crossplane provider controller reconciles it:

  1. Provider reads the desired state from the Crossplane resource spec
  2. Provider calls the cloud API to create/update/delete the corresponding resource
  3. Provider writes the current state (ARNs, connection details, status) back to the resource's status subresource
  4. The resource's READY=True condition means the cloud resource exists and is operational

Connection details (database endpoint, credentials) are written to a Kubernetes Secret that your application reads directly.


Installation

This guide covers Crossplane v1. Crossplane v2 (current stable: 2.4.0) is a deliberate break, not a version bump: Claims are removed entirely, Composite Resources and Managed Resources are namespaced by default, XRDs move to a v2 API with an explicit scope, Crossplane fields relocate under spec.crossplane, native patch-and-transform gives way to composition functions, and ControllerConfig is replaced by DeploymentRuntimeConfig. The 1.17.2 pin below is therefore deliberate — following this post against a v2 install will not work. Treat a v2 migration as a rewrite of your XRDs and Compositions, and read the upstream v2 migration guide before starting.

bash
1helm repo add crossplane-stable https://charts.crossplane.io/stable
2helm repo update
3
4# Check https://github.com/crossplane/crossplane/releases for latest
5helm install crossplane crossplane-stable/crossplane \
6  --namespace crossplane-system \
7  --create-namespace \
8  --version 1.17.2    # Check https://github.com/crossplane/crossplane/releases for latest
9
10kubectl get pods -n crossplane-system
11# crossplane-<hash>                      Running
12# crossplane-rbac-manager-<hash>         Running

AWS Provider Setup

Install the AWS provider (manages EC2, RDS, S3, IAM, and 1000+ other AWS services):

yaml
1apiVersion: pkg.crossplane.io/v1
2kind: Provider
3metadata:
4  name: provider-aws-rds
5spec:
6  package: xpkg.upbound.io/upbound/provider-aws-rds:v1.20.0
7  # Check https://marketplace.upbound.io/providers/upbound/provider-aws-rds for latest

Upbound's AWS providers are split by service family (RDS, S3, IAM, EC2, etc.) — install only the families you need rather than the monolithic provider:

bash
1kubectl apply -f - <<EOF
2apiVersion: pkg.crossplane.io/v1
3kind: Provider
4metadata:
5  name: provider-aws-s3
6spec:
7  package: xpkg.upbound.io/upbound/provider-aws-s3:v1.20.0
8---
9apiVersion: pkg.crossplane.io/v1
10kind: Provider
11metadata:
12  name: provider-aws-rds
13spec:
14  package: xpkg.upbound.io/upbound/provider-aws-rds:v1.20.0
15---
16apiVersion: pkg.crossplane.io/v1
17kind: Provider
18metadata:
19  name: provider-aws-iam
20spec:
21  package: xpkg.upbound.io/upbound/provider-aws-iam:v1.20.0
22EOF
23
24kubectl get provider
25# NAME               INSTALLED   HEALTHY   PACKAGE
26# provider-aws-rds   True        True      ...

ProviderConfig: AWS Auth via Pod Identity

yaml
1apiVersion: aws.upbound.io/v1beta1
2kind: ProviderConfig
3metadata:
4  name: default
5spec:
6  credentials:
7    source: InjectedIdentity    # InjectedIdentity for Pod Identity; IRSA for annotation-based roles

The Crossplane provider pods need an IAM role with permissions to create/manage the resources you're provisioning. Create a Pod Identity association for the provider ServiceAccount:

bash
1# The provider's ServiceAccount is in crossplane-system
2# Each provider has its own SA: provider-aws-rds → crossplane-system/provider-aws-rds-*
3# Use wildcard to handle the hash suffix
4aws eks create-pod-identity-association \
5  --cluster-name production \
6  --namespace crossplane-system \
7  --service-account "provider-aws-rds-*" \    # Exact SA name varies by provider
8  --role-arn arn:aws:iam::${AWS_ACCOUNT_ID}:role/CrossplaneProviderRDS

The IAM role needs permissions to manage the target resources: rds:CreateDBInstance, rds:DescribeDBInstances, rds:DeleteDBInstance, etc.


Managed Resources: Direct AWS Provisioning

A Managed Resource is the 1:1 mapping from a Kubernetes resource to a cloud resource. Managed Resources are always cluster-scoped — they have no namespace. Apply an RDS Instance, get an RDS instance:

yaml
1apiVersion: rds.aws.upbound.io/v1beta1
2kind: Instance    # In provider-aws-rds the kind is Instance, not RDSInstance
3metadata:
4  name: payments-db    # Cluster-scoped: no namespace field
5spec:
6  forProvider:
7    region: us-east-1
8    dbInstanceClass: db.t3.medium
9    engine: postgres
10    engineVersion: "16.4"
11    dbName: payments
12    allocatedStorage: 20
13    storageEncrypted: true
14    multiAZ: false    # Set true for production HA
15    publiclyAccessible: false
16    vpcSecurityGroupIdSelector:
17      matchLabels:
18        crossplane.io/name: payments-db-sg    # Reference a SecurityGroup managed resource
19
20  # Write connection details to this Secret
21  writeConnectionSecretToRef:
22    name: payments-db-conn
23    namespace: payments    # Write the Secret to the app's namespace
24
25  providerConfigRef:
26    name: default

After Crossplane reconciles this:

bash
kubectl get instance payments-db
# NAME          READY   SYNCED   AGE
# payments-db   True    True     5m

kubectl get secret payments-db-conn -n payments
# Contains: endpoint, port, username, password

The Secret contains the connection string your application uses. Update the RDSInstance spec (e.g., change dbInstanceClass) and Crossplane calls ModifyDBInstance — the same reconciliation loop as any Kubernetes controller.


Compositions: Platform-Defined Infrastructure APIs

Managed Resources map 1:1 to AWS resources. Compositions map a single user-facing resource to multiple cloud resources — defining a platform-level interface that hides provider details.

A platform team defines a PostgreSQLInstance API (abstracting over RDS):

yaml
1apiVersion: apiextensions.crossplane.io/v1
2kind: CompositeResourceDefinition
3metadata:
4  name: xpostgresqlinstances.platform.codingprotocols.com
5spec:
6  group: platform.codingprotocols.com
7  names:
8    kind: XPostgreSQLInstance
9    plural: xpostgresqlinstances
10  claimNames:
11    kind: PostgreSQLInstance       # Namespace-scoped Claim (what developers use)
12    plural: postgresqlinstances
13  versions:
14    - name: v1alpha1
15      served: true
16      referenceable: true
17      schema:
18        openAPIV3Schema:
19          type: object
20          properties:
21            spec:
22              type: object
23              properties:
24                parameters:
25                  type: object
26                  required: ["storageGB", "tier"]
27                  properties:
28                    storageGB:
29                      type: integer
30                      minimum: 20
31                      maximum: 1000
32                    tier:
33                      type: string
34                      enum: ["dev", "production"]

Then define the Composition that maps XPostgreSQLInstance to actual AWS resources:

The example below uses the classic spec.resources mode (Crossplane ≤ 1.13 style). Crossplane 1.14+ introduced spec.mode: Pipeline with a function-based model (function-patch-and-transform) — the recommended approach for new Compositions on current versions. The patch semantics are identical; only the outer structure differs.

yaml
1apiVersion: apiextensions.crossplane.io/v1
2kind: Composition
3metadata:
4  name: xpostgresqlinstances.aws.platform.codingprotocols.com
5spec:
6  compositeTypeRef:
7    apiVersion: platform.codingprotocols.com/v1alpha1
8    kind: XPostgreSQLInstance
9
10  resources:
11    - name: rdsinstance
12      base:
13        apiVersion: rds.aws.upbound.io/v1beta1
14        kind: Instance
15        spec:
16          forProvider:
17            region: us-east-1
18            engine: postgres
19            engineVersion: "16.4"
20            publiclyAccessible: false
21            storageEncrypted: true
22          providerConfigRef:
23            name: default
24      patches:
25        # Map tier→instanceClass: dev=t3.micro, production=m6g.large
26        - type: FromCompositeFieldPath
27          fromFieldPath: spec.parameters.tier
28          toFieldPath: spec.forProvider.dbInstanceClass
29          transforms:
30            - type: map
31              map:
32                dev: db.t3.micro
33                production: db.m6g.large
34        # Map storageGB parameter
35        - type: FromCompositeFieldPath
36          fromFieldPath: spec.parameters.storageGB
37          toFieldPath: spec.forProvider.allocatedStorage

Developer Claim

Now developers create a PostgreSQLInstance Claim in their namespace without knowing anything about RDS instance classes, VPCs, or provider configs:

yaml
1# Developer creates this — no AWS knowledge required
2apiVersion: platform.codingprotocols.com/v1alpha1
3kind: PostgreSQLInstance
4metadata:
5  name: payments-db
6  namespace: payments
7spec:
8  parameters:
9    storageGB: 50
10    tier: production
11  writeConnectionSecretToRef:
12    name: payments-db-conn

Crossplane creates the full XPostgreSQLInstance cluster-scoped composite, then provisions the Instance managed resource. The developer sees READY=True when the database is available and reads payments-db-conn for the connection string.


Terraform Day-2 Operations Checklist

State hygiene, drift, imports, policy checks, and upgrade routines — everything after `terraform apply` works. Plain Markdown, commit it to your repo.

Free. Instant download. You'll also get the occasional deep-dive from the newsletter — unsubscribe anytime.

Composition Functions for Logic Beyond Patch-and-Transform

Patch-and-transform (used above) covers field mapping and simple value transforms, but can't express conditional resource creation, loops, or computed values. Crossplane 1.14+'s Composition Functions run arbitrary code — packaged as OCI images — as a pipeline step, for the cases patch-and-transform can't reach:

yaml
1# Function step for logic patch-and-transform can't express
2spec:
3  pipeline:
4    - step: generate-rds-config
5      functionRef:
6        name: function-go-templating   # Go template function
7      input:
8        apiVersion: gotemplating.fn.crossplane.io/v1beta1
9        kind: GoTemplate
10        source: Inline
11        inline:
12          template: |
13            apiVersion: rds.aws.upbound.io/v1beta1
14            kind: Instance
15            spec:
16              forProvider:
17                finalSnapshotIdentifier: {{ .observed.composite.resource.metadata.name }}-final
18                tags:
19                  Name: {{ .observed.composite.resource.metadata.name }}
20                  CostCenter: {{ index .observed.composite.resource.metadata.labels "cost-center" | default "unknown" }}

This particular example computes a snapshot identifier and a cost-allocation tag from the composite's own name and labels — the kind of per-resource templating that a static patch can't produce. Reach for a Function once you need conditional resource creation, loops over a list in the Claim spec, or values computed from more than one field at once.


GitOps Integration with Argo CD

Crossplane CRDs are standard Kubernetes resources — Argo CD manages them the same way it manages Deployments:

yaml
1# Argo CD Application for database claims
2apiVersion: argoproj.io/v1alpha1
3kind: Application
4metadata:
5  name: databases
6  namespace: argocd
7spec:
8  source:
9    repoURL: https://github.com/codingprotocols/k8s-infra
10    path: databases/production    # Directory containing PostgreSQLInstance Claims
11    targetRevision: main
12  destination:
13    server: https://kubernetes.default.svc
14    namespace: payments
15  syncPolicy:
16    automated:
17      prune: true
18      selfHeal: true
19    syncOptions:
20      - CreateNamespace=true

Argo CD sync waves work naturally with Crossplane: use sync wave -1 for Claims (provision the database) and wave 0 for the application Deployment. The application waits for the database to be READY=True before deploying. See Argo CD: GitOps Continuous Delivery for Kubernetes for sync wave configuration.


Frequently Asked Questions

Does Crossplane replace Terraform?

For teams already on Kubernetes, Crossplane can replace Terraform for infrastructure tightly coupled to workloads — databases, queues, IAM roles for IRSA — via GitOps and a single kubectl apply for app plus infra. It's weaker for infrastructure that isn't cluster-adjacent (VPCs, multi-region networking, account IAM), where Terraform's ecosystem is more mature. Realistic split: Crossplane per-workload, Terraform for foundational infrastructure like VPCs and EKS clusters.

What happens to the cloud resource if I delete the Crossplane resource?

By default, Crossplane deletes the cloud resource when you delete the Kubernetes resource (the deletionPolicy defaults to Delete). Set deletionPolicy: Orphan on the managed resource to decouple the Kubernetes resource deletion from the cloud resource deletion — the AWS RDS instance will remain even after the Kubernetes resource is removed:

yaml
spec:
  deletionPolicy: Orphan    # Kubernetes object deletion does NOT delete the RDS instance

Use Orphan for stateful resources in production where an accidental kubectl delete shouldn't destroy data.

How do I import existing AWS resources into Crossplane?

Use the managementPolicies: ["Observe"] spec to observe an existing resource (read-only) without modifying it:

yaml
1spec:
2  managementPolicies: ["Observe"]    # Read-only: never call create/update/delete
3  forProvider:
4    region: us-east-1
5  externalName: "existing-rds-identifier"    # Set to the existing AWS resource ID
6  providerConfigRef:
7    name: default

With Observe policy, Crossplane syncs state from AWS into the resource status but never calls create/update/delete. To transition an observed resource into full Crossplane management, change managementPolicies to ["*"] (or remove the field, which defaults to full management) once you've confirmed the observed state is correct.

What happens if the Crossplane provider pod crashes?

Crossplane is eventually consistent — if a provider is unavailable, reconciliation for its managed resources simply pauses. Cloud resources are not affected; Crossplane doesn't send delete or modify calls while it's down. When the provider recovers, it reconciles every managed resource again and corrects any drift that accumulated in the meantime.

How do I handle secret rotation for managed resources?

Crossplane writes connection details (endpoints, generated passwords) to a Kubernetes Secret via writeConnectionSecretToRef, but it doesn't rotate them on a schedule. For RDS, pair Crossplane with AWS Secrets Manager's automatic rotation and a separate controller that updates the Secret when AWS rotates the password — or manage the Secrets Manager secret itself as a Crossplane managed resource (provider-aws-secretsmanager) alongside the RDS instance so rotation state lives in the same reconciliation graph.

How do I delete a Claim without leaving the database behind, or with it?

Deleting the Claim cascades to the Composite Resource and every Managed Resource it created:

bash
kubectl delete postgresqlinstance payments-db -n payments

If the underlying RDS instance has deletionProtection: true (the cloud-level flag, separate from Crossplane's own deletionPolicy covered above), the delete is blocked at AWS until you clear it:

bash
kubectl patch instance.rds payments-db-xxxxx \
  --type merge -p '{"spec":{"forProvider":{"deletionProtection":false}}}'
kubectl delete postgresqlinstance payments-db -n payments

For data you want to survive a Claim deletion on purpose, set deletionPolicy: Orphan on the managed resource ahead of time rather than relying on deletionProtection as a safety net.


For the GitOps workflow that deploys Crossplane Claims alongside application manifests, see Argo CD: GitOps Continuous Delivery for Kubernetes. For External Secrets Operator that pulls secrets from AWS Secrets Manager into the cluster (a complement to Crossplane's connection Secrets), see Secrets Management on Kubernetes: HashiCorp Vault vs External Secrets Operator.

Evaluating Crossplane for infrastructure management on EKS or designing a Composition-based developer platform? Talk to us at Coding Protocols — we help platform teams implement self-service infrastructure APIs that give developers fast provisioning without direct AWS access.

Official References

Was this article helpful?

Be the first to rate this article

Related Topics

Crossplane
Kubernetes
Infrastructure as Code
AWS
EKS
Platform Engineering
GitOps
Compositions

Found this useful? Share it.

Practice this

Related tools

Read Next