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 XRD (
CompositeResourceDefinition) is the catalog entry — the button on the front of the machine. It declares “we offer a thing calledPostgreSQLInstance; here are the three knobs you may turn (size,storageGB,version); here is the receipt you get back (a connectionendpoint).” It is a contract and nothing more: the shape of the request and the shape of the answer. - The Composition is the recipe wired to the machinery behind the panel — what actually happens when the button is pressed. “For
size: medium, provision onedb.r6g.largeRDS instance, generate a password, attach a subnet group, and surface the address as the endpoint.” Consumers never see the recipe; the platform team owns it and can rewire the machinery without changing the button. - The control plane is the motor that never switches off. Unlike
terraform apply— a one-shot push that ends the moment the command exits — Crossplane’s controllers loop forever, continuously comparing “what the cloud looks like right now” against “what the recipe says it should be” and correcting any gap. Somebody resizes the database by hand at 2am? The motor quietly puts it back.
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.
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:
- Explain the two-layer reconciliation model — composite resources sitting over managed resources — and why conflating the layers is the single most common source of confusion.
- Design a clean XRD that models intent (t-shirt sizes) instead of leaking vendor fields, and that rejects bad input at the schema.
- Author a Composition as a function pipeline (
mode: Pipeline) and know when to reach past patch-and-transform for KCL or Go templating. - Wire namespaced Claims to cluster-scoped XRs, share context with
EnvironmentConfig, and layer RBAC + policy into real multi-tenant guardrails. - Argue precisely where Crossplane’s control-plane model beats — and where it costs more than — a Terraform module wrapped in a CI pipeline.
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
ServiceAccountis 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:
- Model intent, not implementation. Expose
size: small|medium|large, notdbInstanceClass: db.t3.medium. The instance class is a Composition decision; if you change the t-shirt-to-class mapping later, no consumer changes. - Constrain at the schema.
minimum,maximum, andenumare validated by the API server at admission. Bad input is rejected before any cloud call. referenceable: truemarks the version a Composition is allowed to bind to.defaultCompositionReflets a claim omit composition selection entirely.- Status is your output contract.
status.endpointis what consumers read back. Keep it stable.
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:
- Direction matters.
FromCompositeFieldPathreads the XR and writes the MR (input flow).ToCompositeFieldPathreads the MR’s observed status and writes the XR status (output flow). The endpoint round-trips up through the latter. - Transforms are pure functions on a value. The
maptransform turns t-shirt sizes into instance classes inside the pipeline. No conditionals leak to the consumer. - Connection details aggregate up.
writeConnectionSecretToRefon the MR plus theconnectionDetailsblock makes Crossplane publish a secret next to the XR (and, with a claim, copy it into the consumer’s namespace). The application getsendpoint,port, andpasswordas 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:
- RBAC scoped per namespace: a team can create
PostgreSQLInstanceclaims inteam-ordersand nowhere else. They cannot touch XRs or MRs directly. - Constraint policy with Kyverno or Gatekeeper on the claim CRD: enforce mandatory
cost-centerlabels, capstorageGBper environment, or blocksize: largeoutside production. ResourceQuotais not enough on its own because cloud spend is not a cluster resource; the policy layer on claims is where you enforce the real guardrails.
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 (v1alpha1 → v1beta1) 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:
- Layer 1 (platform):
Provider,Function, andConfigurationobjects, plusProviderConfigandDeploymentRuntimeConfig. This is the API itself. Owned by the platform team, gated by review. - Layer 2 (tenants): Claims. Owned by application teams in their own repos or directories, synced into their namespaces.
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:
deletionPolicy(Delete|Orphan) — whether deleting the MR deletes the cloud resource or just stops managing it.managementPolicies— a finer-grained GA control over which actions the controller may take. Setting it to["Observe"]makes the MR read-only: it imports and watches the resource but never writes, which is how you safely bring brownfield infrastructure under observation before you let Crossplane own it.
# 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:
served— clients may read/write this version’s URL.referenceable— a Composition may bind to it (exactly one referenceable version). Multipleservedversions let you migrate consumers gradually; a schema change that renames or removes a field is a new version (v1alpha1→v1beta1), never an edit in place.defaultCompositionRef— the Composition a claim gets if it names none.compositionSelector(label matching) andenforcedCompositionRefgive you policy-driven or locked selection instead.defaultCompositionUpdatePolicy(Automatic|Manual) — whether a live XR automatically adopts a newer Composition revision or waits to be pinned. This is the XRD-level analogue of the package-revision safety you saw in step 8, and in regulated environmentsManualis the sane default.connectionSecretKeys— the allow-list of connection-detail keys that may propagate up to the claim’s secret.
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:
- Legacy inline (
mode: Resources) — aspec.resourcesarray withbase,patches,transforms,connectionDetails, andreadinessCheckswritten 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. - Function pipeline (
mode: Pipeline) — an orderedpipelineof 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 inputapiVersion: 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:
- A claim in
team-orderscreates a backing XR named<claim>-<hash>; the claim’sspec.resourceRefpoints at it, and the XR’sspec.claimRefpoints back. - Connection details flow up: each MR writes a secret via
writeConnectionSecretToRef, the XR aggregates the keys the XRD allows, and the claim copies the result into its namespace underspec.writeConnectionSecretToRef.name. The app mountsorders-db-connand never sees a provider credential. - Tenants get RBAC only on the claim kind in their namespace. XRs and MRs stay platform-only.
kubectl get postgresqlinstance -n team-ordersis their entire, honest inventory.
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 yourcrossplane.yamlversion 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.
-
“Crossplane is just Terraform running inside Kubernetes — I apply it once and I’m done.” There is no “done.” Crossplane never stops reconciling; the moment a Composition is live, its controllers are continuously applying. The right model: it is a control loop, not a command. You are operating a living system, so you plan for continuous apply (staged rollout,
Manualactivation) rather than a discrete change window. -
“I’ll expose
dbInstanceClassandengineVersionon the XRD so teams have full flexibility.” That leaks the implementation into the contract and couples every consumer to AWS. Change clouds, or retire an instance class, and every claim breaks. The right model: the XRD models intent (size: small|medium|large); the vendor mapping lives in the Composition where you can change it without touching a single consumer. -
“Managed resources and composite resources are the same layer.” Conflating them is the Crossplane confusion. The right model: an MR is 1:1 with one cloud resource, reconciled by the provider controller; an XR is your abstraction, reconciled by the composition engine that renders a bundle of MRs. Drift correction is an MR property; abstraction is an XR property.
-
“I’ll just patch the fields — direction doesn’t matter.” It matters completely, and getting it backwards is why
status.endpointstays empty. The right model:FromCompositeFieldPathreads the XR and writes into the MR (input, flowing down);ToCompositeFieldPathreads the MR’s observedstatusand writes it up to the XR (output, flowing up). The endpoint round-trips home via the latter. -
“The pipeline will mark my XR
Readyautomatically like the old engine did.” It will not —mode: Pipelineis explicit about everything. Forgetfunction-auto-readyand your XR reconciles fine but never reportsREADY=True, so anything waiting on it hangs. The right model: readiness is a step you add, not a freebie. -
“Claims and XRs are interchangeable, so I’ll just let teams create XRs.” XRs are cluster-scoped, so handing them out means any team can see and edit any team’s infrastructure — there is no tenant boundary. The right model: tenants get namespaced Claims; XRs and MRs are platform-only, enforced with RBAC.
-
“Upgrading a Composition will destroy and recreate my databases.” Almost never — MRs diff the new desired state against the existing cloud resource and modify in place, so a changed instance class is an RDS modify, not a drop. The right model: a Composition change is an update — unless you change a field the cloud treats as immutable (or the external-name), which can force replacement, which is exactly why you
crossplane renderand userevisionActivationPolicy: Manualbefore activating. -
“I gave the provider an access key in a Secret; that’s fine for now.” That static key governs your entire cloud estate through one
ServiceAccount, and “for now” outlives everyone. The right model: controller identity — IRSA / EKS Pod Identity / Workload Identity — with an IAM policy scoped to tagged resources, on a Tier-0 cluster. (This is the step-1 warning, restated because it is the one people skip.)
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>
- Edit the Composition’s
medium → db.m6g.largemap and package a new ConfigurationRevision (bump the tag; never reuse one). 2. Before activating, runcrossplane renderagainst a representativemediumXR and diff the produced MR against the live one — confirm onlyinstanceClasschanges and nothing that forces replacement (identifier, engine). 3. Install withrevisionActivationPolicy: Manualso the new revision stays inactive. 4. Set a canary: pin one non-critical XR to the new revision (compositionRevisionRefwithcompositionUpdatePolicy: Manual), watch it reconcile toSYNCED/READY, and observe an in-place RDS modify. 5. Activate the revision for the rest; withdefaultCompositionUpdatePolicy: Automaticthe 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 withrenderand a canary rather than trusting it, because changing an immutable field would silently become a replacement. </details>
Glossary
- Control plane — the Kubernetes cluster running Crossplane’s controllers; it holds desired state and reconciles cloud resources continuously. Treat it as Tier-0.
- Managed Resource (MR) — a Kubernetes object mapping 1:1 to one external cloud resource (
Instance.rds.aws.upbound.io→ one RDS DB), reconciled by its provider controller. - Composite Resource (XR) — the higher-level, cluster-scoped abstraction you define; it has no cloud controller and is reconciled by the composition engine into a bundle of MRs.
- CompositeResourceDefinition (XRD) — the object (
apiextensions.crossplane.io/v1) that defines an XR’s schema and identity; applying it generates the XR CRD and, withclaimNames, the Claim CRD. - Claim — a namespaced, 1:1, consumer-facing proxy for an XR; the only object tenants interact with, scoped by RBAC.
- Composition — the recipe (
apiextensions.crossplane.io/v1) that tells Crossplane which MRs to render for an XR, and how to patch them. - Composition function — a gRPC service Crossplane calls in the pipeline; it receives observed + desired state and returns updated desired state.
- Function pipeline (
mode: Pipeline) — the current Composition form: an ordered list of function steps. Replaces the deprecated inlinemode: Resources. function-patch-and-transform— the function that provides classic base-plus-patches field plumbing (inputpt.fn.crossplane.io/v1beta1).function-go-templating/function-kcl— logic functions for loops, conditionals, and validation, in Go templates or KCL respectively.function-auto-ready— marks the XRReadyonce its composed resources are ready; required in a pipeline because readiness is no longer implicit.function-environment-configs— loadsEnvironmentConfigdata into the pipeline’s in-memory environment.function-sequencer— orders MR creation when one resource must exist before another.- Provider — a package (OCI image) of MR CRDs plus their controllers for one cloud/family (e.g.
provider-aws-rds). - ProviderConfig — how a provider authenticates and which identity/scope it uses; referenced per MR via
providerConfigRef(defaultdefault). - DeploymentRuntimeConfig — customises how a provider’s controller pod runs (e.g. the annotated
ServiceAccountfor IRSA). - Connection secret /
connectionDetails— key/values (endpoint, port, password) an MR publishes; aggregated up the XR and copied into the claim’s namespace. - External name (
crossplane.io/external-name) — the annotation tying an MR to its real cloud resource’s identifier. managementPolicies— the GA control over which actions an MR controller may take (Observe,Create,Update,Delete,LateInitialize);["Observe"]is read-only import.deletionPolicy(Delete|Orphan) — whether deleting the MR deletes or merely releases the cloud resource.- EnvironmentConfig — a cluster-scoped bag of key/values (
apiextensions.crossplane.io/v1alpha1) supplying per-environment context to Compositions. - Usage — a resource (
apiextensions.crossplane.io/v1alpha1) that blocks deletion of one resource while another depends on it (spec.ofprotected whilespec.byexists). - Configuration (xpkg) — a versioned, signed OCI package bundling XRDs, Compositions, and Functions; the unit you roll out and roll back.
- ConfigurationRevision — an immutable installed revision of a Configuration; rollback is repointing the tag and reactivating the prior revision.
revisionActivationPolicy(Automatic|Manual) — whether a newly installed package revision activates immediately or waits for you to flip it.- Reconciliation — the observe → diff → act loop every controller runs continuously; the source of Crossplane’s automatic drift correction.
- Patch (
FromCompositeFieldPath/ToCompositeFieldPath) — moves a value from the XR down into an MR (input) or from an MR’s status up to the XR (output).