Platform Engineering
14 min readMay 7, 2026

Crossplane: Cloud Infrastructure as Kubernetes Resources

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.

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

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

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.


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 operating Kubernetes, Crossplane can replace Terraform for cloud infrastructure that is tightly coupled to Kubernetes workloads (databases, queues, IAM roles for IRSA). The GitOps workflow, familiar kubectl commands, and unified lifecycle management (app + infra in one kubectl apply) are the main advantages. Crossplane is weaker for infrastructure that's not cluster-adjacent (VPCs, multi-region networking, account-level IAM), where Terraform's provider ecosystem is more mature. The realistic approach: Crossplane for per-workload infrastructure (databases, queues), Terraform for foundational infrastructure (VPCs, EKS clusters, route tables).

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.


For the Crossplane Composition model, Pipeline mode with function-patch-and-transform, and a deep dive on XRDs and Claims, see Crossplane: Cloud Infrastructure as Kubernetes Resources. 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.

Related Topics

Crossplane
Kubernetes
Infrastructure as Code
AWS
EKS
Platform Engineering
GitOps
Compositions

Read Next