Terraform Lesson 76 of 89

Building an Internal Cloud API with Crossplane Compositions and XRDs

Most platform teams end up writing a Terraform module, wrapping it in a CI pipeline, and calling that “self-service.” It isn’t. Application teams still file tickets, wait on plan approvals, and have no live representation of what they own. Crossplane flips the model: you define an API in your Kubernetes cluster, application teams kubectl apply a small claim, and a reconciler continuously drives cloud resources to match. The platform team ships a versioned API; consumers never see a provider credential or a *.tf file.

This guide stands up a control plane, designs a XPostgreSQLInstance abstraction, wires it to AWS RDS through a Composition, and hardens it for multi-tenant, GitOps-driven delivery. Everything targets Crossplane v1.15+ with the new function-based composition pipeline, which is the path forward now that native patch-and-transform has moved into a function itself.

In a nutshell

Level: Advanced · Time: ~30 min

Picture your Kubernetes cluster as a vending machine for cloud infrastructure. An application team walks up, presses a button labelled “medium Postgres,” and out drops a real, running database — no ticket, no *.tf file, no console. Crossplane is what turns your cluster into that vending machine, and three pieces make it work. This whole lesson is about those three.

The one sentence to carry through the lesson: an XRD defines a self-service cloud API inside your cluster, a Composition is the recipe that turns one request into a bundle of real cloud resources, and the control plane reconciles them forever instead of once.

Crossplane XRD → Composition → managed resources

The diagram traces one request end to end: an app team applies a namespaced Claim, that Claim creates a cluster-scoped XR whose shape the XRD fixed, the Crossplane engine selects the matching Composition and runs its function pipeline, and the rendered managed resources drive real cloud APIs — with the connection secret published back into the team’s namespace.

This is an advanced, platform-engineering lesson. It assumes you are comfortable with Kubernetes objects — CRDs, controllers, RBAC, namespaces — and have provisioned cloud infrastructure before, ideally with Terraform, since the comparison to terraform apply runs throughout. If the idea of a controller that reconciles desired state against reality is new, the core IaC concepts lesson on state, drift, and idempotency is good grounding for the “reconcile, don’t apply” mindset at the heart of Crossplane. After this lesson you will be able to:

1. Install Crossplane and configure a provider with controller identity

Crossplane is a set of controllers plus a handful of CRDs. Install it with the official Helm chart into its own namespace.

helm repo add crossplane-stable https://charts.crossplane.io/stable
helm repo update

helm install crossplane crossplane-stable/crossplane \
  --namespace crossplane-system \
  --create-namespace \
  --version 1.18.0 \
  --wait

kubectl get pods -n crossplane-system

Crossplane core does nothing on its own. You install a Provider package to get managed resources for a cloud. The modern AWS provider is split into family packages, so you only pull the controllers you need. Install the RDS family:

# provider-aws-rds.yaml
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
  name: provider-aws-rds
spec:
  package: xpkg.upbound.io/upbound/provider-aws-rds:v1.21.0
kubectl apply -f provider-aws-rds.yaml
kubectl get providers
kubectl wait provider.pkg/provider-aws-rds --for=condition=Healthy --timeout=300s

Controller identity, not static keys

Do not feed the provider a long-lived access key. On EKS, attach an IAM role to the provider’s controller ServiceAccount via IRSA (or EKS Pod Identity). The provider controller runs as a per-provider ServiceAccount that you target with a DeploymentRuntimeConfig:

# runtime-config.yaml
apiVersion: pkg.crossplane.io/v1beta1
kind: DeploymentRuntimeConfig
metadata:
  name: irsa-runtime
spec:
  serviceAccountTemplate:
    metadata:
      annotations:
        eks.amazonaws.com/role-arn: arn:aws:iam::111122223333:role/crossplane-rds-provider
---
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
  name: provider-aws-rds
spec:
  package: xpkg.upbound.io/upbound/provider-aws-rds:v1.21.0
  runtimeConfigRef:
    name: irsa-runtime

Then tell the provider to source credentials from the pod’s environment rather than a Kubernetes secret:

# providerconfig.yaml
apiVersion: aws.upbound.io/v1beta1
kind: ProviderConfig
metadata:
  name: default
spec:
  credentials:
    source: IRSA

The blast radius of this ServiceAccount is your entire RDS estate. Scope the IAM policy to the exact actions the provider needs (rds:* on tagged resources), and treat the control plane cluster as Tier-0 infrastructure with its own hardened access path.

2. Managed Resources vs Composite Resources: the reconciliation model

Two reconciliation layers stack here, and conflating them is the most common source of confusion.

A Managed Resource (MR) is a 1:1 Kubernetes representation of one external cloud resource: one Instance.rds.aws.upbound.io maps to one RDS DB instance. Its provider controller runs an external-name-keyed reconcile loop: observe the cloud, diff against spec.forProvider, and call the cloud API to converge. This is where drift correction lives. If someone resizes the instance in the AWS console, the MR controller reverts it on the next reconcile.

A Composite Resource (XR) is a higher-level object you define. It has no cloud controller of its own. Instead, Crossplane’s composition engine reconciles it by rendering a set of MRs from a Composition, applying them, and propagating their status back up. The XR is the unit of abstraction; the MRs are the implementation.

Concern Managed Resource Composite Resource
Maps to One external resource A bundle of resources
Reconciled by Provider controller Crossplane composition engine
API surface Vendor-shaped, hundreds of fields Platform-team-shaped, a handful
Drift correction Yes, directly Indirectly, via child MRs
Who writes it Provider author You

The mental model: MRs are the assembly language of your cloud; XRs are the functions you expose. You are about to define the function signature.

3. Design an XRD that exposes a clean platform-team API

A CompositeResourceDefinition (XRD) defines the schema and identity of your XR. This is the contract. Spend your design effort here, because every consumer and every Composition depends on it, and breaking it later is a versioned migration.

# xrd-postgres.yaml
apiVersion: apiextensions.crossplane.io/v1
kind: CompositeResourceDefinition
metadata:
  name: xpostgresqlinstances.platform.acme.io
spec:
  group: platform.acme.io
  names:
    kind: XPostgreSQLInstance
    plural: xpostgresqlinstances
  claimNames:
    kind: PostgreSQLInstance
    plural: postgresqlinstances
  defaultCompositionRef:
    name: xpostgres-aws
  versions:
    - name: v1alpha1
      served: true
      referenceable: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                parameters:
                  type: object
                  properties:
                    storageGB:
                      type: integer
                      minimum: 20
                      maximum: 1000
                    size:
                      type: string
                      enum: ["small", "medium", "large"]
                    version:
                      type: string
                      default: "16"
                  required:
                    - size
              required:
                - parameters
            status:
              type: object
              properties:
                endpoint:
                  type: string
                  description: Connection hostname for the database.

Design notes that separate a good XRD from a leaky one:

Apply it, and Crossplane generates the XR CRD plus, because you set claimNames, a namespaced claim CRD:

kubectl apply -f xrd-postgres.yaml
kubectl get xrd xpostgresqlinstances.platform.acme.io
kubectl get crd | grep platform.acme.io

4. Author a Composition with the function pipeline, patches, and connection secrets

A Composition tells Crossplane how to render MRs for one XR. As of v1.14+, Compositions run a pipeline of functions rather than the legacy inline resources array. The patch-and-transform behavior everyone knows now lives in function-patch-and-transform. Install it first:

# function-pnt.yaml
apiVersion: pkg.crossplane.io/v1
kind: Function
metadata:
  name: function-patch-and-transform
spec:
  package: xpkg.upbound.io/crossplane-contrib/function-patch-and-transform:v0.7.0

Now the Composition. It maps the abstract size to a concrete instance class, wires storage, and exposes the database endpoint as a connection detail.

# composition-aws.yaml
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
  name: xpostgres-aws
spec:
  compositeTypeRef:
    apiVersion: platform.acme.io/v1alpha1
    kind: XPostgreSQLInstance
  mode: Pipeline
  pipeline:
    - step: patch-and-transform
      functionRef:
        name: function-patch-and-transform
      input:
        apiVersion: pt.fn.crossplane.io/v1beta1
        kind: Resources
        resources:
          - name: rds-instance
            base:
              apiVersion: rds.aws.upbound.io/v1beta1
              kind: Instance
              spec:
                forProvider:
                  region: us-east-1
                  engine: postgres
                  publiclyAccessible: false
                  skipFinalSnapshot: true
                  autoGeneratePassword: true
                  passwordSecretRef:
                    namespace: crossplane-system
                    name: rds-creds
                    key: password
                  username: masteruser
                writeConnectionSecretToRef:
                  namespace: crossplane-system
            connectionDetails:
              - name: endpoint
                type: FromFieldPath
                fromFieldPath: status.atProvider.address
              - name: port
                type: FromFieldPath
                fromFieldPath: status.atProvider.port
            patches:
              - type: FromCompositeFieldPath
                fromFieldPath: spec.parameters.version
                toFieldPath: spec.forProvider.engineVersion
              - type: FromCompositeFieldPath
                fromFieldPath: spec.parameters.storageGB
                toFieldPath: spec.forProvider.allocatedStorage
              - type: FromCompositeFieldPath
                fromFieldPath: spec.parameters.size
                toFieldPath: spec.forProvider.instanceClass
                transforms:
                  - type: map
                    map:
                      small: db.t3.medium
                      medium: db.r6g.large
                      large: db.r6g.2xlarge
              - type: ToCompositeFieldPath
                fromFieldPath: status.atProvider.address
                toFieldPath: status.endpoint
              - type: FromCompositeFieldPath
                fromFieldPath: metadata.uid
                toFieldPath: spec.forProvider.tags["crossplane-uid"]

Three patterns worth internalizing:

  1. Direction matters. FromCompositeFieldPath reads the XR and writes the MR (input flow). ToCompositeFieldPath reads the MR’s observed status and writes the XR status (output flow). The endpoint round-trips up through the latter.
  2. Transforms are pure functions on a value. The map transform turns t-shirt sizes into instance classes inside the pipeline. No conditionals leak to the consumer.
  3. Connection details aggregate up. writeConnectionSecretToRef on the MR plus the connectionDetails block makes Crossplane publish a secret next to the XR (and, with a claim, copy it into the consumer’s namespace). The application gets endpoint, port, and password as a single secret it can mount.

5. Composition Functions for logic beyond patch-and-transform

Patch-and-transform is declarative, which is its strength and its ceiling. The moment you need a loop (fan out N subnets), a conditional resource (create a replica only for large), or cross-resource arithmetic, write a Composition Function. Functions are gRPC services Crossplane calls in the pipeline; they receive the observed state and return a desired state.

You can write them in Go with function-sdk-go, but for most platform logic KCL via function-kcl is faster to ship and reads cleanly. The KCL function takes the observed XR and emits desired MRs:

# main.k  (KCL function logic, conditional replica)
oxr = option("params").oxr  # observed composite resource
size = oxr.spec.parameters.size

_items = [
    {
        apiVersion = "rds.aws.upbound.io/v1beta1"
        kind = "Instance"
        metadata.name = oxr.metadata.name + "-primary"
        spec.forProvider = {
            region = "us-east-1"
            engine = "postgres"
            instanceClass = "db.r6g.2xlarge" if size == "large" else "db.t3.medium"
        }
    }
]

# Only large tiers get a read replica.
if size == "large":
    _items += [{
        apiVersion = "rds.aws.upbound.io/v1beta1"
        kind = "Instance"
        metadata.name = oxr.metadata.name + "-replica"
        spec.forProvider.replicateSourceDb = oxr.metadata.name + "-primary"
    }]

items = _items

Wire it as an additional pipeline step. Steps run in order, and later steps see the desired state produced by earlier ones, so you can layer function-kcl for logic and function-patch-and-transform for field plumbing in the same Composition:

  pipeline:
    - step: render-resources
      functionRef:
        name: function-kcl
      input:
        apiVersion: krm.kcl.dev/v1alpha1
        kind: KCLInput
        spec:
          source: |
            # ... main.k contents inline, or reference an OCI module ...
    - step: auto-ready
      functionRef:
        name: function-auto-ready

function-auto-ready is the small but essential companion: it marks the XR Ready once its composed resources are ready, which the legacy engine did implicitly but the pipeline does not.

6. Claims, namespaces, and multi-tenancy

XRs are cluster-scoped, which is wrong for tenant self-service. The Claim is the namespaced, consumer-facing front door you got for free by setting claimNames on the XRD. An application team applies this into their namespace:

# claim.yaml  (lives in the team's namespace)
apiVersion: platform.acme.io/v1alpha1
kind: PostgreSQLInstance
metadata:
  name: orders-db
  namespace: team-orders
spec:
  parameters:
    size: medium
    storageGB: 100
  writeConnectionSecretToRef:
    name: orders-db-conn

The claim creates a backing cluster-scoped XR, the XR renders MRs, and the resulting connection secret lands as orders-db-conn in team-orders. Tenant isolation comes from standard Kubernetes primitives layered on top:

This is the payoff. The team’s mental model is “I own a PostgreSQLInstance,” and kubectl get postgresqlinstance -n team-orders is a true inventory of what they have.

7. Package and version Configurations and Providers as xpkg images

Loose YAML in a repo is a prototype, not a platform. Bundle your XRDs, Compositions, and Functions into a Configuration package, an OCI image (.xpkg) you version, sign, and roll out like any other artifact. Define the package metadata with dependencies it needs at install time:

# crossplane.yaml
apiVersion: meta.pkg.crossplane.io/v1
kind: Configuration
metadata:
  name: platform-postgres
spec:
  crossplane:
    version: ">=v1.18.0"
  dependsOn:
    - provider: xpkg.upbound.io/upbound/provider-aws-rds
      version: ">=v1.21.0"
    - function: xpkg.upbound.io/crossplane-contrib/function-patch-and-transform
      version: ">=v0.7.0"

Build and push with the crossplane CLI. The build packages every Crossplane resource in the directory into a single image:

# Build the xpkg from the directory holding crossplane.yaml + XRDs + Compositions
crossplane xpkg build \
  --package-root=. \
  --package-file=platform-postgres.xpkg

# Push to your registry, tagged like any OCI artifact
crossplane xpkg push \
  --package-files=platform-postgres.xpkg \
  registry.acme.io/platform/postgres:v1.2.0

Now a Configuration object is all a downstream cluster needs; Crossplane resolves and pulls the declared provider and function dependencies automatically:

apiVersion: pkg.crossplane.io/v1
kind: Configuration
metadata:
  name: platform-postgres
spec:
  package: registry.acme.io/platform/postgres:v1.2.0

Versioning discipline mirrors any API: a backward-compatible Composition change is a patch or minor bump; an XRD schema change that removes or renames a field is a new XRD version (v1alpha1v1beta1) with both versions served during migration. Never reuse a tag.

8. GitOps delivery, upgrade, and rollback

The control plane’s desired state belongs in Git, reconciled by Argo CD or Flux. The cluster holds two distinct GitOps layers, and keeping them separate is what makes upgrades safe:

A package upgrade is a one-line change to a Configuration’s tag in Layer 1. Crossplane installs the new package revision alongside the old, then activates it. Because packages are immutable revisions, rollback is repointing the tag in Git and letting Argo sync; Crossplane reactivates the prior ConfigurationRevision. Control activation explicitly to avoid surprise jumps:

apiVersion: pkg.crossplane.io/v1
kind: Configuration
metadata:
  name: platform-postgres
spec:
  package: registry.acme.io/platform/postgres:v1.2.0
  revisionActivationPolicy: Manual
  revisionHistoryLimit: 3

With Manual activation, a new revision installs but stays inactive until you flip it, giving you a window to validate rendered output before live XRs reconcile against the new Composition. The critical safety property: upgrading a Composition does not delete and recreate live cloud resources. The MR controllers diff the new desired state against existing infrastructure and converge in place, so a tightened instance class is an RDS modify, not a destroy. Always dry-run the new Composition against a representative XR before activation.

Going deeper

The eight steps above are the build. This section is the why underneath them — the mechanics an experienced engineer needs to run this in production and to reason about it against the Terraform model they already know.

Reconcile forever, not apply once

The deepest difference between Crossplane and Terraform is not syntax; it is when work happens. terraform apply is an event: it reads your config, refreshes state, builds a plan by diffing the two against the real world, applies it, and exits. Between runs nothing is watching. Drift accumulates silently until the next apply or a scheduled terraform plan in CI catches it.

Crossplane is a process. Every managed resource has a provider controller running an unending observe → diff → act loop: read the live cloud resource, compare it to spec.forProvider, and call the cloud API to close any gap — then wait for the resync interval (--poll-interval, ~1 minute by default) and do it again. There is no walk-away window. This is why the lesson keeps saying “drift correction lives here”: a console hotfix that resizes an instance is reverted on the next loop without a human running anything. The cost of that guarantee is that the cluster is now Tier-0 infrastructure — it is always applying, so you secure and observe it like the most privileged system you own.

Terraform Crossplane
Execution model One-shot plan/apply, then exits Controllers reconcile continuously
Where desired state lives .tf files + a state file Kubernetes objects in etcd
Where observed state lives State file (refreshed on run) status.atProvider on each MR, live
Drift between runs Undetected until next run Corrected automatically each resync
Change gate plan review before apply None by default — reconcile is immediate
Blast-radius control Human approves each plan revisionActivationPolicy: Manual + policy
Consumer interface Run the tool / a pipeline kubectl apply a claim
Multi-tenant self-service Bolt on (TFC/Spacelift + RBAC) Native (claims + namespace RBAC)

Notice the trade in the last four rows: Crossplane hands you self-service and continuous correction, but you give up Terraform’s built-in “look before you leap” plan gate. You buy it back deliberately — with crossplane render, manual revision activation, and admission policy — rather than getting it for free.

Managed resources, providers, and ProviderConfig

A managed resource (MR) is the atom. Instance.rds.aws.upbound.io is one RDS DB instance; spec.forProvider is what you want, status.atProvider is what the controller last observed, and the crossplane.io/external-name annotation is the key that ties the Kubernetes object to the real cloud resource (the DB identifier). Two fields change an MR’s whole behaviour:

# observe-only: import an existing DB, never let Crossplane mutate it
apiVersion: rds.aws.upbound.io/v1beta1
kind: Instance
metadata:
  name: legacy-orders-db
  annotations:
    crossplane.io/external-name: legacy-orders-db
spec:
  managementPolicies: ["Observe"]
  forProvider:
    region: us-east-1

A Provider is a package (an OCI image) of MR CRDs plus their controllers. A ProviderConfig is separate on purpose: it holds how to authenticate and which identity to use, and each MR points at one via spec.providerConfigRef (defaulting to the one named default). That separation is what lets a single provider serve many accounts — a ProviderConfig per AWS account or region, selected per MR or patched in by the Composition. Keep the two ideas distinct: the Provider is the capability, the ProviderConfig is the credential and scope.

The XRD generates two CRDs — and it is a public API

Applying an XRD does something concrete: Crossplane synthesises a cluster-scoped XR CRD (kind = spec.names.kind, here XPostgreSQLInstance) and, only if you set claimNames, a namespaced Claim CRD (kind = spec.claimNames.kind, here PostgreSQLInstance). Omit claimNames and you get an XR-only abstraction — correct for platform-owned infrastructure that no tenant should claim.

Because it is a CRD, the XRD is a versioned public API, and the fields around versions[] are the levers you will actually reach for:

The design rule from step 3 is worth restating as an API rule: model intent, constrain hard, keep status stable. Everything downstream — every claim, every Composition, every consumer’s mounted secret — is coupled to this schema, so breaking it is a migration, not an edit.

Composition: legacy patch-and-transform vs the function pipeline

Compositions have two historical shapes, and knowing which era a snippet is from saves hours:

  1. Legacy inline (mode: Resources) — a spec.resources array with base, patches, transforms, connectionDetails, and readinessChecks written directly in the Composition. This is the syntax most old blog posts show. It is deprecated and removed from current Crossplane; do not write new Compositions this way.
  2. Function pipeline (mode: Pipeline) — an ordered pipeline of composition functions, which is what step 4 uses and what you should write today. Even the classic patch-and-transform behaviour now lives inside a function, function-patch-and-transform, with input apiVersion: pt.fn.crossplane.io/v1beta1, kind: Resources.

Functions are gRPC services Crossplane calls in order; each receives the observed state plus the desired state produced so far, and returns an updated desired state. The ones worth knowing:

Function Use it for
function-patch-and-transform Declarative field plumbing: base MRs, patches, transforms, connection details
function-go-templating Loops/conditionals in Helm-like templates; comfortable for teams who know Go templates
function-kcl Loops/conditionals/validation in KCL; strong typing, OCI-packaged modules
function-environment-configs Load shared EnvironmentConfig data into the pipeline’s environment
function-auto-ready Mark the XR Ready once composed resources are ready
function-sequencer Order resource creation (e.g. subnet group before instance)

function-go-templating is the most approachable escape hatch from pure patch-and-transform. The same conditional-replica logic you saw in KCL reads like a Helm chart:

    - step: render-with-templates
      functionRef:
        name: function-go-templating
      input:
        apiVersion: gotemplating.fn.crossplane.io/v1beta1
        kind: GoTemplate
        source: Inline
        inline:
          template: |
            {{- $size := .observed.composite.resource.spec.parameters.size }}
            apiVersion: rds.aws.upbound.io/v1beta1
            kind: Instance
            metadata:
              annotations:
                {{ setResourceNameAnnotation "primary" }}
            spec:
              forProvider:
                region: us-east-1
                engine: postgres
                instanceClass: {{ if eq $size "large" }}db.r6g.2xlarge{{ else }}db.t3.medium{{ end }}

The rule of thumb: patch-and-transform for plumbing, a real function (KCL or go-templating) the moment you need a loop, a conditional resource, or cross-resource arithmetic. Mixing them in one pipeline is normal and encouraged.

Claims vs XRs: the tenancy boundary

The XR is cluster-scoped — infrastructure rarely belongs to one namespace, and cluster scope lets the platform see the whole estate. But cluster scope is exactly wrong for tenant self-service: anyone who could create an XR could create anyone’s database. The Claim is the fix — a namespaced 1:1 proxy for an XR. The relationship is precise:

Version caveat. This namespaced-Claim / cluster-XR split is the Crossplane v1 model, and everything in this lesson targets v1 (apiextensions.crossplane.io/v1). Crossplane v2 makes XRs themselves namespaceable and de-emphasises Claims; if you are reading a v2 tutorial, the claim indirection may be gone. Pin your mental model — and your crossplane.yaml version constraint — to the major version you actually run.

EnvironmentConfig: shared context without hardcoding

A Composition should not bake region, vpcId, or a subnet-group name into its YAML — those differ per environment, and copy-pasting a Composition per environment defeats the point. An EnvironmentConfig is a cluster-scoped bag of key/values you reference from the pipeline:

apiVersion: apiextensions.crossplane.io/v1alpha1
kind: EnvironmentConfig
metadata:
  name: env-prod
data:
  region: us-east-1
  vpcId: vpc-0abc1234def567890
  dbSubnetGroup: prod-db-subnets

function-environment-configs loads one or more into an in-memory environment that later functions read. Selection can be by name or by label, so the same Composition renders differently in env-prod versus env-staging:

    - step: load-environment
      functionRef:
        name: function-environment-configs
      input:
        apiVersion: environmentconfigs.fn.crossplane.io/v1beta1
        kind: Input
        spec:
          environmentConfigs:
            - type: Reference
              ref:
                name: env-prod

Downstream, function-patch-and-transform reads the environment with an environment-scoped patch:

              - type: FromEnvironmentFieldPath
                fromFieldPath: region
                toFieldPath: spec.forProvider.region

The payoff mirrors Terraform’s *.tfvars per workspace, but the values live in the cluster and are reconciled, not passed on a CLI you have to remember to run.

Ordering and safe deletion: Usage

Two independent MRs may have a real dependency the composition engine does not infer — an RDS instance needs its subnet group to exist first, and must be deleted last. Creation order can be handled with function-sequencer; the deletion/protection side is the Usage resource, which blocks deletion of one resource while another depends on it:

apiVersion: apiextensions.crossplane.io/v1alpha1
kind: Usage
metadata:
  name: rds-uses-subnetgroup
spec:
  replayDeletion: true
  of:
    apiVersion: rds.aws.upbound.io/v1beta1
    kind: SubnetGroup
    resourceRef:
      name: prod-db-subnets
  by:
    apiVersion: rds.aws.upbound.io/v1beta1
    kind: Instance
    resourceRef:
      name: orders-db-primary

Usage puts a webhook-enforced finalizer on the of resource, so an attempt to delete the subnet group while the instance still references it is rejected outright. This is the Crossplane answer to the dependency-ordering that Terraform derives automatically from its resource graph — in Crossplane you sometimes state it explicitly.

Where this beats Terraform, and where it costs more

Be honest with yourself and your stakeholders. Crossplane genuinely wins on: day-2 drift correction (continuous, not a scheduled scan), native self-service (a claim + namespace RBAC is a real product API, not a wrapped pipeline), live inventory (kubectl get is the source of truth, not a state file you hope is fresh), and multi-tenancy (Kubernetes RBAC + admission policy you already run). It costs more on: operational surface (you run, secure, back up, and upgrade a Tier-0 cluster and its providers), the missing plan gate (reconciliation is immediate; you reintroduce “look before you leap” with crossplane render, Manual activation, and policy), ecosystem breadth (Terraform’s provider and module registry is older and wider), and the learning curve (XRD + Composition + functions is more moving parts than one main.tf). Many teams run both: Terraform for the control-plane cluster and account bootstrap, Crossplane for the self-service abstractions that ride on top. For the platform-maturity ladder that leads here, see Architecting the IaC ladder; for the drift model both tools care about, Drift detection and reconciliation.

Verify

Walk the full path from API definition to live infrastructure and confirm each layer reconciled.

# 1. Core, providers, and functions are healthy
kubectl get providers,functions
kubectl get pkgrev   # every revision should be Healthy + Active

# 2. The API surface exists
kubectl get xrd
kubectl get crd | grep platform.acme.io

# 3. Apply a claim and watch it resolve through the layers
kubectl apply -f claim.yaml
kubectl get postgresqlinstance -n team-orders     # the claim
kubectl get xpostgresqlinstance                    # the backing XR
kubectl get instance.rds.aws.upbound.io            # the rendered MR

# 4. Trace composition rendering and any reconcile errors
kubectl describe xpostgresqlinstance <name>        # Synced/Ready conditions + events

# 5. Confirm the connection secret was published to the tenant namespace
kubectl get secret orders-db-conn -n team-orders \
  -o jsonpath='{.data.endpoint}' | base64 -d

Render a Composition locally before it ever touches the cluster. crossplane render runs the function pipeline against an example XR and prints the MRs it would produce, which is your fast feedback loop and your pre-activation safety check:

crossplane render xr.yaml composition-aws.yaml functions.yaml

A healthy system shows SYNCED=True and READY=True on the claim, the XR, and every MR, and the published secret resolves to a real RDS endpoint.

Checklist

Common beginner mistakes

These are misconceptions — the wrong mental model that spawns a whole class of bugs — not symptom-and-fix entries. Each pairs the belief with the correction.

Practice challenges

Work these top to bottom; they escalate from “name the layer” to “upgrade 200 live databases safely.” Each solution is one click away — try first, then check. Where a live control plane is handy you can run the manifests for real; where none is, the answers are still schema-correct.

Challenge 1 (Beginner) — Name the layer. Classify each object as Claim, XR, Composition, XRD, or Managed Resource, and say which namespace (if any) it lives in: (a) Instance.rds.aws.upbound.io; (b) XPostgreSQLInstance; © PostgreSQLInstance in team-orders; (d) xpostgres-aws; (e) xpostgresqlinstances.platform.acme.io.

<details> <summary>Solution</summary>

(a) Managed Resource, cluster-scoped — 1:1 with one RDS instance. (b) Composite Resource (XR), cluster-scoped — the abstraction. © Claim, namespaced in team-orders — the tenant front door. (d) Composition, cluster-scoped — the recipe. (e) XRD (CompositeResourceDefinition), cluster-scoped — the API contract that generated (b) and ©. Why: only Claims are namespaced; everything else in the Crossplane control plane is cluster-scoped, which is exactly why tenants get Claims and nothing else. </details>

Challenge 2 (Beginner) — Fix a leaky XRD. A teammate’s XRD parameters expose instanceClass: string, allocatedStorage: integer, engineVersion: string, and masterUsername: string. Which belong on the platform API, and which are Composition concerns? Rewrite the parameters.

<details> <summary>Solution</summary>

Keep only what models intent: size (enum small|medium|large), storageGB (integer with minimum/maximum), and arguably version (a constrained enum, not a free string). Drop instanceClass (a Composition mapping from size) and masterUsername (an implementation detail the Composition sets and rotates). Why: every field on the XRD is a promise you must keep forever; exposing instanceClass welds your public API to one cloud’s SKU names. </details>

Challenge 3 (Intermediate) — The endpoint that never appears. A Composition renders the RDS MR correctly, the DB comes up, but status.endpoint on the XR stays empty. The relevant patch reads: type: FromCompositeFieldPath, fromFieldPath: status.atProvider.address, toFieldPath: status.endpoint. Find the bug.

<details> <summary>Solution</summary>

The direction is inverted. FromCompositeFieldPath reads the composite (XR) and writes the MR — but status.atProvider.address is an MR field, so this patch reads nothing useful and writes nothing back. It must be type: ToCompositeFieldPath, fromFieldPath: status.atProvider.address, toFieldPath: status.endpoint to read the MR’s observed status and write it up to the XR. Why: output values flow up via ToCompositeFieldPath; input values flow down via FromCompositeFieldPath. </details>

Challenge 4 (Intermediate) — Conditional replica. You must add a read replica only when size: large, leaving small/medium as a single instance. Can function-patch-and-transform alone do this? If not, what does, and why?

<details> <summary>Solution</summary>

No — patch-and-transform can patch fields on resources it already declares, but it cannot conditionally create a resource; the replica MR would always render. Use a logic function: function-kcl or function-go-templating, emitting the replica MR inside an if size == "large" block (as in step 5’s KCL, or the go-templating example in Going deeper). Why: declarative patching operates on a fixed set of resources; adding or removing resources by condition is control flow, which is what functions are for. </details>

Challenge 5 (Advanced) — Enforce a real guardrail. Product requires: size: large is allowed only in namespaces labelled env=prod, and every claim must carry a cost-center label. Which layer enforces each rule, and why can’t the XRD do it?

<details> <summary>Solution</summary>

The XRD schema cannot express either rule: it validates a claim’s own fields at admission but cannot see the namespace’s labels or require metadata the schema doesn’t own, and cross-object context is out of scope. Enforce both with an admission policy on the Claim CRD — Kyverno or Gatekeeper — that reads size, the namespace’s env label, and metadata.labels["cost-center"], plus RBAC scoping who may create claims where. Why: schema validation is per-object and context-free; policy engines see the whole request (object + namespace + user) and are the correct place for contextual, cross-object rules. </details>

Challenge 6 (Advanced) — Upgrade 200 live databases safely. You must change the medium mapping from db.r6g.large to db.m6g.large across 200 live PostgreSQLInstances with no recreation and a controlled blast radius. Give the ordered rollout, and how you prove it is a modify, not a replace.

<details> <summary>Solution</summary>

  1. Edit the Composition’s medium → db.m6g.large map and package a new ConfigurationRevision (bump the tag; never reuse one). 2. Before activating, run crossplane render against a representative medium XR and diff the produced MR against the live one — confirm only instanceClass changes and nothing that forces replacement (identifier, engine). 3. Install with revisionActivationPolicy: Manual so the new revision stays inactive. 4. Set a canary: pin one non-critical XR to the new revision (compositionRevisionRef with compositionUpdatePolicy: Manual), watch it reconcile to SYNCED/READY, and observe an in-place RDS modify. 5. Activate the revision for the rest; with defaultCompositionUpdatePolicy: Automatic the remaining XRs adopt it and modify in place on their next reconcile. Why: an instance-class change is a modify, not a destroy — but you verify that with render and a canary rather than trusting it, because changing an immutable field would silently become a replacement. </details>

Glossary

crossplanekubernetesplatform-engineeringcompositionscontrol-plane
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments