Terraform is the dominant infrastructure-as-code tool, but it has a fundamental limitation: it runs as a pipeline step, not a continuously running control loop. Once terraform apply finishes, Terraform is done. If someone manually changes a resource, nothing detects it until the next apply. If a resource drifts or gets deleted, Terraform doesn’t react.
Crossplane takes a different approach. It extends Kubernetes with custom resource definitions for every cloud resource — an S3 bucket is a Bucket object, an RDS instance is a RDSInstance, a VPC is a VPC. Kubernetes controllers continuously reconcile declared state against actual state, exactly like they do for Deployment and Service. Drift is detected and corrected automatically. Resources are first-class Kubernetes objects, so all your GitOps tooling, RBAC, and observability works on infrastructure too.
This guide covers the full Crossplane stack: installing providers, creating managed resources, building composite resources that package infrastructure into developer-friendly abstractions, and running a production platform where developers self-serve databases and buckets without touching AWS credentials.
The Architecture
┌─────────────────────────────────────────────────────────────┐
│ Kubernetes Cluster │
│ (Management / Platform) │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Crossplane Core │ │
│ │ ┌──────────────┐ ┌───────────────────────────┐ │ │
│ │ │ Provider │ │ Composite Resource Engine │ │ │
│ │ │ AWS/GCP/ │ │ (XRDs + Compositions) │ │ │
│ │ │ Azure/... │ └───────────────────────────┘ │ │
│ │ └──────────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ Managed Resources: Composite Resources: Claims: │
│ RDSInstance XPostgresDatabase PostgresDB │
│ S3Bucket XAppEnvironment AppEnvironment │
│ VPC XKubernetesCluster KubernetesClus │
└─────────────────────────────────────────────────────────────┘
│ Reconcile loop: create/update/delete
▼
AWS / GCP / Azure / DigitalOcean / Helm / Terraform / ...
Key Concepts
| Concept |
What it is |
Analogy |
| Provider |
A Crossplane plugin that talks to a cloud API |
Terraform provider |
| Managed Resource (MR) |
A single cloud resource (one RDS instance, one S3 bucket) |
Terraform resource |
| ProviderConfig |
Credentials and settings for a provider |
Terraform provider {} block |
| CompositeResourceDefinition (XRD) |
Defines a new CRD that represents an abstraction |
Terraform module interface |
| Composition |
The implementation: maps a composite resource to MRs |
Terraform module body |
| Composite Resource (XR) |
An instance of an XRD (cluster-scoped) |
Terraform module instance |
| Claim |
A namespace-scoped handle to a composite resource |
Developer’s view of the resource |
The key insight: claims are what developers interact with. A developer creates a PostgresDatabase claim in their namespace. The platform team’s Composition provisions the actual RDS instance, subnet group, security group, parameter group, and secrets — all from one claim object.
Installation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
# Install Crossplane into a Kubernetes cluster
helm repo add crossplane-stable https://charts.crossplane.io/stable
helm repo update
helm install crossplane crossplane-stable/crossplane \
--namespace crossplane-system \
--create-namespace \
--set args='{--enable-composition-functions}' \
--set "args={--enable-composition-functions,--enable-composition-webhook-schema-validation}" \
--wait
# Verify
kubectl get pods -n crossplane-system
# crossplane-xxxx 1/1 Running
# crossplane-rbac-manager 1/1 Running
|
Providers
Providers are Crossplane’s plugins — they translate Kubernetes reconciliation loops into cloud API calls.
Installing the AWS Provider Family
The AWS provider is split into a family of focused sub-providers (each installing only the CRDs you need):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
|
# 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
runtimeConfigRef:
name: provider-aws
---
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
name: provider-aws-rds
spec:
package: xpkg.upbound.io/upbound/provider-aws-rds:v1
runtimeConfigRef:
name: provider-aws
---
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
name: provider-aws-ec2
spec:
package: xpkg.upbound.io/upbound/provider-aws-ec2:v1
runtimeConfigRef:
name: provider-aws
---
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
name: provider-aws-iam
spec:
package: xpkg.upbound.io/upbound/provider-aws-iam:v1
runtimeConfigRef:
name: provider-aws
|
1
2
3
4
5
6
7
|
kubectl apply -f provider-aws.yaml
# Watch providers become healthy
kubectl get providers
# NAME INSTALLED HEALTHY PACKAGE AGE
# provider-aws-s3 True True xpkg.upbound.io/upbound/provider-aws-s3 2m
# provider-aws-rds True True xpkg.upbound.io/upbound/provider-aws-rds 2m
|
Authenticating to AWS
1
2
3
4
5
6
7
8
|
# Create a secret with AWS credentials
kubectl create secret generic aws-credentials \
--namespace crossplane-system \
--from-file=credentials=./aws-credentials # Standard ~/.aws/credentials format
# [default]
# aws_access_key_id = AKIAIOSFODNN7EXAMPLE
# aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
|
1
2
3
4
5
6
7
8
9
10
11
12
|
# providerconfig-aws.yaml
apiVersion: aws.upbound.io/v1beta1
kind: ProviderConfig
metadata:
name: default
spec:
credentials:
source: Secret
secretRef:
namespace: crossplane-system
name: aws-credentials
key: credentials
|
Using IRSA (IAM Roles for Service Accounts) — recommended for EKS:
1
2
3
4
5
6
7
|
apiVersion: aws.upbound.io/v1beta1
kind: ProviderConfig
metadata:
name: default
spec:
credentials:
source: IRSA # Uses the provider pod's service account annotation
|
1
2
3
4
5
6
7
8
9
10
|
# The provider pod's service account needs the IRSA annotation:
apiVersion: pkg.crossplane.io/v1alpha1
kind: ControllerConfig
metadata:
name: provider-aws
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/crossplane-provider
spec:
serviceAccountAnnotations:
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/crossplane-provider
|
Managed Resources
Managed Resources are the lowest-level Crossplane objects — one-to-one with a cloud API resource.
S3 Bucket
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
|
apiVersion: s3.aws.upbound.io/v1beta1
kind: Bucket
metadata:
name: my-app-assets-prod
annotations:
crossplane.io/external-name: my-app-assets-prod-us-east-1 # The actual AWS name
spec:
forProvider:
region: us-east-1
tags:
Environment: production
Team: platform
ManagedBy: crossplane
# What happens to the AWS resource if this object is deleted
deletionPolicy: Delete # or: Orphan (leave the resource, just remove the CR)
providerConfigRef:
name: default
---
# Bucket versioning (a separate AWS resource)
apiVersion: s3.aws.upbound.io/v1beta1
kind: BucketVersioning
metadata:
name: my-app-assets-prod-versioning
spec:
forProvider:
region: us-east-1
bucketRef:
name: my-app-assets-prod # References the Bucket above
versioningConfiguration:
- status: Enabled
providerConfigRef:
name: default
---
# Block public access
apiVersion: s3.aws.upbound.io/v1beta1
kind: BucketPublicAccessBlock
metadata:
name: my-app-assets-prod-pab
spec:
forProvider:
region: us-east-1
bucketRef:
name: my-app-assets-prod
blockPublicAcls: true
blockPublicPolicy: true
ignorePublicAcls: true
restrictPublicBuckets: true
providerConfigRef:
name: default
|
1
2
3
4
5
6
7
8
9
|
kubectl apply -f bucket.yaml
# Watch reconciliation
kubectl get bucket my-app-assets-prod
# NAME READY SYNCED EXTERNAL-NAME AGE
# my-app-assets-prod True True my-app-assets-prod-us-east-1 45s
# Describe for full status including conditions
kubectl describe bucket my-app-assets-prod
|
RDS PostgreSQL Instance
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
|
apiVersion: rds.aws.upbound.io/v1beta1
kind: Instance
metadata:
name: app-postgres-prod
spec:
forProvider:
region: us-east-1
engine: postgres
engineVersion: "16.2"
instanceClass: db.t3.medium
allocatedStorage: 20
storageType: gp3
storageEncrypted: true
dbName: appdb
username: dbadmin
# Password from a secret
passwordSecretRef:
namespace: crossplane-system
name: rds-master-password
key: password
vpcSecurityGroupIdRefs:
- name: rds-security-group
dbSubnetGroupNameRef:
name: private-subnet-group
backupRetentionPeriod: 7
backupWindow: "03:00-04:00"
maintenanceWindow: "sun:04:00-sun:05:00"
deletionProtection: true
skipFinalSnapshot: false
finalSnapshotIdentifier: app-postgres-prod-final
tags:
Environment: production
Team: platform
# Write the connection details to a secret
writeConnectionSecretsToNamespace: crossplane-system
connectionDetails:
- type: FromConnectionSecretKey
name: endpoint
fromConnectionSecretKey: endpoint
- type: FromConnectionSecretKey
name: port
fromConnectionSecretKey: port
- type: FromConnectionSecretKey
name: username
fromConnectionSecretKey: username
- type: FromConnectionSecretKey
name: password
fromConnectionSecretKey: password
providerConfigRef:
name: default
deletionPolicy: Orphan # Don't delete production databases automatically
|
Composite Resource Definitions (XRDs)
This is where Crossplane’s platform-building power shows up. An XRD defines a new API type — your own PostgresDatabase CRD — that hides the complexity of the underlying managed resources.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
|
# xrd-postgres-database.yaml
apiVersion: apiextensions.crossplane.io/v1
kind: CompositeResourceDefinition
metadata:
name: xpostgresdatabases.platform.mycompany.com
spec:
group: platform.mycompany.com
names:
kind: XPostgresDatabase # Cluster-scoped composite resource
plural: xpostgresdatabases
claimNames:
kind: PostgresDatabase # Namespace-scoped claim (what devs create)
plural: postgresdatabases
# Which namespaces can use this claim
defaultCompositionRef:
name: postgres-database-aws
# Define the schema developers fill in
versions:
- name: v1alpha1
served: true
referenceable: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
parameters:
type: object
required: [size, engine-version]
properties:
size:
type: string
description: "Database size tier: small, medium, large"
enum: [small, medium, large]
default: small
engine-version:
type: string
description: "PostgreSQL major version"
enum: ["14", "15", "16"]
default: "16"
storage-gb:
type: integer
description: "Storage in GiB (min 20, max 1000)"
minimum: 20
maximum: 1000
default: 20
backup-retention-days:
type: integer
description: "How many days to retain backups"
minimum: 1
maximum: 35
default: 7
high-availability:
type: boolean
description: "Enable Multi-AZ for high availability"
default: false
required: [parameters]
status:
type: object
properties:
endpoint:
type: string
description: "Database connection endpoint"
port:
type: integer
description: "Database port"
ready:
type: boolean
|
Compositions
A Composition is the implementation of an XRD — it defines how to translate a composite resource into actual managed resources.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
|
# composition-postgres-aws.yaml
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: postgres-database-aws
labels:
provider: aws
db: postgres
spec:
compositeTypeRef:
apiVersion: platform.mycompany.com/v1alpha1
kind: XPostgresDatabase
# Connection secret propagation from composed resources to the composite
writeConnectionSecretsToNamespace: crossplane-system
resources:
# Resource 1: RDS Parameter Group
- name: parameter-group
base:
apiVersion: rds.aws.upbound.io/v1beta1
kind: ParameterGroup
spec:
forProvider:
region: us-east-1
family: postgres16 # overridden by patches below
description: "Managed by Crossplane"
parameter:
- name: log_connections
value: "1"
- name: log_min_duration_statement
value: "1000" # Log queries >1s
providerConfigRef:
name: default
patches:
# Map size → instance class
- type: CombineFromComposite
combine:
variables:
- fromFieldPath: spec.parameters.engine-version
strategy: string
string:
fmt: "postgres%s"
toFieldPath: spec.forProvider.family
# Resource 2: DB Subnet Group (references existing subnets by tag)
- name: subnet-group
base:
apiVersion: rds.aws.upbound.io/v1beta1
kind: SubnetGroup
spec:
forProvider:
region: us-east-1
description: "Managed by Crossplane"
subnetIdSelector:
matchLabels:
tier: private
vpc: main
providerConfigRef:
name: default
# Resource 3: Security Group
- name: security-group
base:
apiVersion: ec2.aws.upbound.io/v1beta1
kind: SecurityGroup
spec:
forProvider:
region: us-east-1
description: "RDS access - managed by Crossplane"
vpcIdSelector:
matchLabels:
name: main-vpc
ingress:
- fromPort: 5432
toPort: 5432
protocol: tcp
cidrBlocks:
- 10.0.0.0/8 # Allow from internal network only
providerConfigRef:
name: default
# Resource 4: The RDS Instance
- name: rds-instance
base:
apiVersion: rds.aws.upbound.io/v1beta1
kind: Instance
spec:
forProvider:
region: us-east-1
engine: postgres
engineVersion: "16" # patched below
instanceClass: db.t3.small # patched below
allocatedStorage: 20 # patched below
storageType: gp3
storageEncrypted: true
dbName: appdb
username: dbadmin
passwordSecretRef:
namespace: crossplane-system
name: rds-master-password-template # patched to be unique per instance
key: password
dbSubnetGroupNameRef:
name: "" # patched to reference our subnet-group
vpcSecurityGroupIdRefs:
- name: "" # patched to reference our security-group
backupRetentionPeriod: 7 # patched below
multiAZ: false # patched below
deletionProtection: true
skipFinalSnapshot: false
writeConnectionSecretToRef:
namespace: crossplane-system
name: "" # patched to be unique
providerConfigRef:
name: default
deletionPolicy: Orphan
patches:
# Map developer's "size" to an instance class
- type: FromCompositeFieldPath
fromFieldPath: spec.parameters.size
toFieldPath: spec.forProvider.instanceClass
transforms:
- type: map
map:
small: db.t3.small
medium: db.t3.medium
large: db.r6g.large
# Map size to storage
- type: FromCompositeFieldPath
fromFieldPath: spec.parameters.size
toFieldPath: spec.forProvider.allocatedStorage
transforms:
- type: map
map:
small: 20
medium: 100
large: 500
# Patch engine version
- type: FromCompositeFieldPath
fromFieldPath: spec.parameters.engine-version
toFieldPath: spec.forProvider.engineVersion
# Patch storage size from explicit parameter
- type: FromCompositeFieldPath
fromFieldPath: spec.parameters.storage-gb
toFieldPath: spec.forProvider.allocatedStorage
# Patch backup retention
- type: FromCompositeFieldPath
fromFieldPath: spec.parameters.backup-retention-days
toFieldPath: spec.forProvider.backupRetentionPeriod
# Patch high-availability flag
- type: FromCompositeFieldPath
fromFieldPath: spec.parameters.high-availability
toFieldPath: spec.forProvider.multiAZ
# Give the connection secret a unique name based on the composite resource
- type: FromCompositeFieldPath
fromFieldPath: metadata.name
toFieldPath: spec.writeConnectionSecretToRef.name
transforms:
- type: string
string:
fmt: "%s-connection"
# Reference the subnet group created above
- type: FromCompositeFieldPath
fromFieldPath: metadata.name
toFieldPath: spec.forProvider.dbSubnetGroupNameRef.name
transforms:
- type: string
string:
fmt: "%s-subnet-group"
# Write the endpoint back to composite resource status
- type: ToCompositeFieldPath
fromFieldPath: status.atProvider.address
toFieldPath: status.endpoint
- type: ToCompositeFieldPath
fromFieldPath: status.atProvider.port
toFieldPath: status.port
connectionDetails:
- type: FromConnectionSecretKey
name: endpoint
fromConnectionSecretKey: endpoint
- type: FromConnectionSecretKey
name: port
fromConnectionSecretKey: port
- type: FromConnectionSecretKey
name: username
fromConnectionSecretKey: username
- type: FromConnectionSecretKey
name: password
fromConnectionSecretKey: password
|
Claims: The Developer Experience
With the XRD and Composition in place, developers create a simple PostgresDatabase claim in their own namespace. They never see the RDS instance, subnet group, security group, or parameter group — just a clean API.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
# Developer creates this in their app's namespace
apiVersion: platform.mycompany.com/v1alpha1
kind: PostgresDatabase
metadata:
name: my-app-db
namespace: team-payments
spec:
parameters:
size: medium
engine-version: "16"
storage-gb: 100
backup-retention-days: 14
high-availability: true
writeConnectionSecretToRef:
name: my-app-db-connection # Secret created in team-payments namespace
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
# Developer applies and waits
kubectl apply -f postgres-claim.yaml
kubectl get postgresdb my-app-db -n team-payments
# NAME READY SYNCED CONNECTION-SECRET AGE
# my-app-db True True my-app-db-connection 8m
# The connection secret is available in their namespace
kubectl get secret my-app-db-connection -n team-payments -o jsonpath='{.data}' | \
jq 'to_entries | map({(.key): (.value | @base64d)}) | add'
# {
# "endpoint": "my-app-db-xxxx.us-east-1.rds.amazonaws.com",
# "port": "5432",
# "username": "dbadmin",
# "password": "..."
# }
# Use it in a Deployment
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: my-app-db-connection
key: endpoint
|
RBAC for Claims
Lock down who can create which claim types:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
# Platform admin creates this ClusterRole
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: database-requester
rules:
- apiGroups: [platform.mycompany.com]
resources: [postgresdatabases]
verbs: [get, list, watch, create, update, patch, delete]
---
# Bind to the payments team's service account
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: database-requester
namespace: team-payments
subjects:
- kind: Group
name: team-payments # Maps to a GitHub team via OIDC/RBAC integration
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: database-requester
apiGroup: rbac.authorization.k8s.io
|
Composition Functions
Standard Compositions use patches — a declarative but limited DSL. Composition Functions replace patches with real code (Go, Python, or any language in a container), enabling logic that patches can’t express: conditionals, loops, external lookups.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
# Install the Go templating function
apiVersion: pkg.crossplane.io/v1beta1
kind: Function
metadata:
name: function-go-templating
spec:
package: xpkg.upbound.io/crossplane-contrib/function-go-templating:v0.7.0
---
# Install the auto-ready function (marks composite ready when all resources are ready)
apiVersion: pkg.crossplane.io/v1beta1
kind: Function
metadata:
name: function-auto-ready
spec:
package: xpkg.upbound.io/crossplane-contrib/function-auto-ready:v0.3.0
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
|
# Composition using pipeline-mode functions
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: postgres-database-aws-fn
spec:
compositeTypeRef:
apiVersion: platform.mycompany.com/v1alpha1
kind: XPostgresDatabase
mode: Pipeline
pipeline:
# Step 1: Generate resources using Go templates
- step: create-resources
functionRef:
name: function-go-templating
input:
apiVersion: gotemplating.fn.crossplane.io/v1beta1
kind: GoTemplate
source: Inline
inline:
template: |
{{- $name := .observed.composite.resource.metadata.name }}
{{- $params := .observed.composite.resource.spec.parameters }}
{{- $size := $params.size | default "small" }}
{{- $instanceClasses := dict
"small" "db.t3.small"
"medium" "db.t3.medium"
"large" "db.r6g.large"
}}
{{- $storageSizes := dict
"small" 20
"medium" 100
"large" 500
}}
---
apiVersion: rds.aws.upbound.io/v1beta1
kind: Instance
metadata:
annotations:
gotemplating.fn.crossplane.io/composition-resource-name: rds-instance
name: {{ $name }}
spec:
forProvider:
region: us-east-1
engine: postgres
engineVersion: {{ $params.engineVersion | default "16" | quote }}
instanceClass: {{ get $instanceClasses $size }}
allocatedStorage: {{ $params.storageGb | default (get $storageSizes $size) }}
storageEncrypted: true
multiAZ: {{ $params.highAvailability | default false }}
backupRetentionPeriod: {{ $params.backupRetentionDays | default 7 }}
{{- if eq $size "large" }}
# Performance Insights for large instances
performanceInsightsEnabled: true
performanceInsightsRetentionPeriod: 7
{{- end }}
providerConfigRef:
name: default
# Step 2: Mark composite ready when all resources are ready
- step: automatically-detect-ready
functionRef:
name: function-auto-ready
|
Custom Function in Python
For complex logic, write a full function:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
|
# functions/python-database-fn/main.py
import grpc
from crossplane.function.proto.v1 import run_function_pb2 as fnv1
from crossplane.function.proto.v1 import run_function_pb2_grpc as fnv1grpc
import json
class FunctionRunner(fnv1grpc.FunctionRunnerServicer):
def RunFunction(self, request, context):
response = fnv1.RunFunctionResponse()
# Read the composite resource
composite = json.loads(request.observed.composite.resource.value)
params = composite["spec"]["parameters"]
name = composite["metadata"]["name"]
env = composite["metadata"]["labels"].get("environment", "dev")
# Complex logic: different configs per environment
if env == "production":
deletion_policy = "Orphan"
backup_retention = max(params.get("backupRetentionDays", 14), 14)
deletion_protection = True
else:
deletion_policy = "Delete"
backup_retention = params.get("backupRetentionDays", 3)
deletion_protection = False
# Build the desired resources
rds_instance = {
"apiVersion": "rds.aws.upbound.io/v1beta1",
"kind": "Instance",
"metadata": {"name": name},
"spec": {
"deletionPolicy": deletion_policy,
"forProvider": {
"region": "us-east-1",
"engine": "postgres",
"backupRetentionPeriod": backup_retention,
"deletionProtection": deletion_protection,
# ... rest of spec
}
}
}
# Add to desired resources
resource = response.desired.resources["rds-instance"]
resource.resource.value = json.dumps(rds_instance).encode()
return response
|
Multi-Environment Compositions
A real platform needs different behavior in dev vs production. Use Composition selectors:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
|
# dev-composition.yaml
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: postgres-database-dev
labels:
environment: dev
provider: aws
spec:
compositeTypeRef:
apiVersion: platform.mycompany.com/v1alpha1
kind: XPostgresDatabase
resources:
- name: rds-instance
base:
spec:
forProvider:
instanceClass: db.t3.micro # Always tiny in dev
allocatedStorage: 20
multiAZ: false
deletionProtection: false
skipFinalSnapshot: true
deletionPolicy: Delete # Clean up dev DBs automatically
---
# prod-composition.yaml
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: postgres-database-prod
labels:
environment: production
provider: aws
spec:
compositeTypeRef:
apiVersion: platform.mycompany.com/v1alpha1
kind: XPostgresDatabase
resources:
- name: rds-instance
base:
spec:
forProvider:
# Respects developer's size parameter
deletionProtection: true
skipFinalSnapshot: false
deletionPolicy: Orphan
|
1
2
3
4
5
6
7
8
9
10
11
|
# Update the XRD to select composition by label
spec:
compositeTypeRef:
apiVersion: platform.mycompany.com/v1alpha1
kind: XPostgresDatabase
compositionSelector:
matchLabels:
environment: production # Selects prod composition
# Or let developers choose (with validation):
# The claim can specify a compositionRef or compositionSelector
|
GitOps Integration
Crossplane objects are just Kubernetes manifests — ArgoCD and Flux work out of the box.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
# argocd-application.yaml — manage infrastructure via GitOps
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: platform-infrastructure
namespace: argocd
spec:
project: platform
source:
repoURL: git@github.com:mycompany/platform-config.git
targetRevision: main
path: infrastructure/crossplane
destination:
server: https://kubernetes.default.svc
namespace: crossplane-system
syncPolicy:
automated:
prune: true # Remove resources deleted from git
selfHeal: true # Re-apply if someone manually edits
syncOptions:
- CreateNamespace=true
- ServerSideApply=true # Required for large CRDs
|
infrastructure/crossplane/
├── providers/
│ ├── provider-aws-s3.yaml
│ ├── provider-aws-rds.yaml
│ └── providerconfig-aws.yaml
├── xrds/
│ ├── xrd-postgres-database.yaml
│ ├── xrd-s3-bucket.yaml
│ └── xrd-app-environment.yaml
├── compositions/
│ ├── composition-postgres-dev.yaml
│ ├── composition-postgres-prod.yaml
│ └── composition-s3-bucket.yaml
└── kustomization.yaml
The most powerful pattern is a single claim that provisions an entire application environment — database, bucket, IAM role, cache — all from one object.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
|
# xrd-app-environment.yaml
apiVersion: apiextensions.crossplane.io/v1
kind: CompositeResourceDefinition
metadata:
name: xappenvironments.platform.mycompany.com
spec:
group: platform.mycompany.com
names:
kind: XAppEnvironment
plural: xappenvironments
claimNames:
kind: AppEnvironment
plural: appenvironments
versions:
- name: v1alpha1
served: true
referenceable: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: [parameters]
properties:
parameters:
type: object
required: [app-name, environment]
properties:
app-name:
type: string
environment:
type: string
enum: [dev, staging, production]
db-size:
type: string
enum: [small, medium, large]
default: small
needs-cache:
type: boolean
default: false
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
# Developer creates ONE object to provision everything their app needs:
apiVersion: platform.mycompany.com/v1alpha1
kind: AppEnvironment
metadata:
name: payments-staging
namespace: team-payments
spec:
parameters:
app-name: payments
environment: staging
db-size: medium
needs-cache: true
writeConnectionSecretToRef:
name: payments-staging-config
|
Behind the scenes, the Composition provisions: an RDS instance, an S3 bucket for file uploads, an ElastiCache cluster (if needs-cache: true), an IAM role with least-privilege access, and a Kubernetes ConfigMap with all the connection details. All in one claim.
| Dimension |
Crossplane |
Terraform |
| Execution model |
Continuous reconciliation (control loop) |
Run-to-completion pipeline step |
| Drift detection |
Automatic, continuous |
Manual (terraform plan) |
| State storage |
Kubernetes etcd |
Remote state (S3, Terraform Cloud) |
| API |
Kubernetes objects (kubectl, GitOps) |
HCL files + CLI |
| Composition |
XRDs + Compositions |
Modules |
| Developer self-service |
Claims (namespace-scoped RBAC) |
Requires CLI access or CI job |
| Secrets |
Kubernetes Secrets (ESO integration) |
Sensitive outputs in state |
| Multi-cloud |
Multiple providers in one cluster |
Multiple providers in one codebase |
| Maturity |
Newer, fast-moving |
Mature, huge ecosystem |
| Learning curve |
High (K8s expertise required) |
Medium (HCL is approachable) |
| Best for |
Platform teams building self-service |
Ops teams managing infrastructure directly |
The two tools aren’t mutually exclusive. The provider-terraform lets Crossplane manage Terraform workspaces, so you can use Terraform for resources without a native Crossplane provider while still getting Crossplane’s reconciliation and RBAC.
Observability and Debugging
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
# Check if a managed resource is healthy
kubectl get managed
kubectl get instances.rds.aws.upbound.io
# See why a resource is not READY or SYNCED
kubectl describe instance.rds.aws.upbound.io my-db
# Look for: Conditions → type=Ready, type=Synced
# Common issues:
# - "cannot observe": provider can't reach AWS (credentials/network)
# - "cannot create": IAM permissions missing
# - "unexpected state": AWS is in a transitional state (wait)
# View events on any Crossplane object
kubectl get events --field-selector involvedObject.name=my-app-db -n team-payments
# Check the composite resource and all its composed resources
kubectl get composite
kubectl get xpostgresdatabase my-app-db
# Trace the full claim → composite → managed resource chain
kubectl get postgresdb my-app-db -n team-payments -o jsonpath='{.spec.resourceRef}'
kubectl get xpostgresdatabase <name-from-above>
kubectl get instance.rds.aws.upbound.io <name-from-above>
# Check provider health
kubectl get providerrevision
kubectl describe provider provider-aws-rds
# View provider logs for debugging
kubectl logs -n crossplane-system \
-l pkg.crossplane.io/revision=provider-aws-rds-xxx \
--tail=100
|
1
2
3
4
5
6
7
8
|
# Enable debug logging for a provider
apiVersion: pkg.crossplane.io/v1alpha1
kind: ControllerConfig
metadata:
name: provider-aws-debug
spec:
args:
- --debug
|
Prometheus Metrics
Crossplane exposes metrics at :8080/metrics:
1
2
3
4
5
6
7
8
9
10
11
|
# Managed resources by health status
crossplane_managed_resource_exists{synced="False"}
# Composition function latency
histogram_quantile(0.99, rate(crossplane_fn_runner_call_duration_seconds_bucket[5m]))
# Reconciliation errors
rate(crossplane_managed_resource_reconcile_errors_total[5m])
# Resource count by kind
count by (group, version, kind) (crossplane_managed_resource_exists)
|
Best Practices
Design XRDs for your developers, not your infrastructure.
The claim API is a product. Ask developers what they need to specify, hide everything else. A developer shouldn’t need to know what a subnet group is.
Start with small, focused XRDs.
Don’t try to build a universal AppEnvironment on day one. Start with PostgresDatabase, get it right, then compose upward.
Always use deletionPolicy: Orphan for stateful production resources.
Delete is convenient but dangerous — one kubectl delete away from losing a production database. Use Orphan for anything that holds data, and manage deletion explicitly.
Version your XRDs.
Start at v1alpha1. Plan for how you’ll evolve the API. Adding optional fields is non-breaking. Removing or renaming fields requires a new version and a migration.
Use Composition Functions for complex logic.
When you find yourself wishing patches had if/else, switch to functions. The go-templating function covers 80% of cases; custom Python/Go functions cover the rest.
Test compositions with crossplane render.
The crossplane CLI can render a composition locally without a cluster:
1
2
3
4
5
|
crossplane render \
claim.yaml \
composition.yaml \
functions.yaml \
--observed-resources observed.yaml
|
Filed under: Modern Infrastructure Patterns
Comments