LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Crossplane: Infrastructure as Kubernetes — Composites, Providers, and Replacing Terraform

crossplanekubernetesinfrastructureplatform-engineeringiacdevopsawsgitops

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

A Full Platform: AppEnvironment Abstraction

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.


Crossplane vs Terraform

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