Based on the first post in this series: Crossplane Is Not Terraform in Kubernetes.
Crossplane Composition Is Not Templating #
After the first post, you might think the story is simple. Managed resources replace Terraform providers. Composition replaces Terraform modules. Same declarative model, different YAML structure.
I thought that too when I first read about Crossplane v2 composition. I had spent years writing Terragrunt modules. A composition looked familiar: you define variables, reference them in YAML templates, and get cloud resources out the other end. Modular HCL in YAML clothing.
I was wrong. Composition is not a template engine. It is an API framework. The templates are implementation details. The product is a custom Kubernetes resource that lives next to Deployment and Service.
Why not just give teams managed resources and let them figure it out?
You can. Crossplane managed resources work fine on their own. The provider controller handles reconciliation, state management, and drift correction. Your team creates a Bucket, an SQS Queue, and a QueuePolicy, and everything works.
The problem is not capability. It is cognitive load. Every application team that needs an event-driven S3 pipeline must understand three managed resources, their interdependencies, the IAM policy format, and the cross-resource references. That is knowledge your platform team already has. Composition lets you package it into a single XNotification resource that the application team creates in three lines of YAML.1
The argument for composition is not about what Crossplane can provision. It is about who holds the complexity.
So composition is like a Terraform module? You template some YAML and call it with parameters?
This is the question that took me longest to answer honestly. Yes, both let you reuse infrastructure patterns. Yes, both accept inputs and produce resources.
The difference is architectural. A Terraform module is a block of configuration you call from another block of configuration. It has no existence outside the run that invokes it. A Crossplane composition is a first-class API endpoint. When you apply an XRD, your custom resource type registers with the Kubernetes API server. Run kubectl api-resources and it appears next to Deployment and Service. Access control is RBAC. Consumers create instances with kubectl apply, the same way they create Pods. There is no init, plan, or apply phase. There is only desired state and reconciliation.2
A composition has three pieces, and keeping them separate in your head changes how you design:
- CompositeResourceDefinition (XRD) defines the schema of your API. What fields does it accept? Which are required?
- Composition defines the implementation. What managed resources should Crossplane create when someone uses the API?
- Composed resources are what the pipeline actually creates.
flowchart LR
subgraph D["API contract"]
XRD["CompositeResourceDefinition
XRD"]
end
subgraph P["Pipeline"]
C["Composition
function pipeline"]
end
subgraph R["Managed resources"]
MR1["S3 Bucket"]
MR2["SQS Queue"]
end
XRD --> C
C --> MR1
C --> MR2
The XRD is your API contract. The Composition is your implementation. Separate them and you can change how resources are created without breaking consumers. You can version the contract independently of the implementation.2
Show me one working.
Say you want a pattern where app teams create an event notification channel: when files land in an S3 bucket, the system pushes a notification to a queue. No SQS policy hand-editing, no IAM setup. The team writes this:
apiVersion: storage.example.org/v1alpha1
kind: XNotification
metadata:
name: image-uploads
spec:
region: us-east-2
bucketName: my-app-image-uploadsUnder the hood, Crossplane provisions an S3 bucket, an SQS queue, and a queue policy that lets S3 send messages to the queue.
The XRD #
The XRD defines the schema. region and bucketName are required. Everything else can have defaults:
apiVersion: apiextensions.crossplane.io/v2
kind: CompositeResourceDefinition
metadata:
name: xnotifications.storage.example.org
spec:
group: storage.example.org
names:
kind: XNotification
plural: xnotifications
scope: Namespaced
versions:
- name: v1alpha1
served: true
referenceable: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required:
- region
- bucketName
properties:
region:
type: string
description: "AWS region"
bucketName:
type: string
description: "Name of the S3 bucket"Every v2 XRD must use apiextensions.crossplane.io/v2, set scope: Namespaced or Cluster, and include at least one version. Only one version can be referenceable: true (the internal storage version). The old claimNames and connectionSecretKeys fields are v1-only and rejected by v2.3
The Composition #
The composition tells Crossplane what to do when someone creates an XNotification. It uses a pipeline of composition functions. The standard pipeline has two steps: render the managed resources with function-go-templating, then let function-auto-ready mark the XR as ready when all resources are healthy.4
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: xnotifications.storage.example.org-v1alpha1
spec:
compositeTypeRef:
apiVersion: storage.example.org/v1alpha1
kind: XNotification
mode: Pipeline
pipeline:
- step: render
functionRef:
name: function-go-templating
input:
apiVersion: gotemplating.fn.crossplane.io/v1beta1
kind: GoTemplate
source: Inline
inline:
template: |
---
apiVersion: s3.aws.m.upbound.io/v1beta1
kind: Bucket
metadata:
annotations:
{{ setResourceNameAnnotation "bucket" }}
spec:
forProvider:
region: {{ .observed.composite.resource.spec.region }}
providerConfigRef:
name: default
kind: ClusterProviderConfig
---
apiVersion: sqs.aws.m.upbound.io/v1beta1
kind: Queue
metadata:
annotations:
{{ setResourceNameAnnotation "queue" }}
spec:
forProvider:
region: {{ .observed.composite.resource.spec.region }}
writeConnectionSecretToRef:
name: {{ .observed.composite.resource.metadata.name }}-queue-conn
providerConfigRef:
name: default
kind: ClusterProviderConfig
---
{{ $obs := default (dict) $.observed.resources }}
{{ $data := dig "queue" (dict) $obs }}
{{ $arn := dig "resource" "status" "atProvider" "arn" "" $data }}
apiVersion: sqs.aws.m.upbound.io/v1beta1
kind: QueuePolicy
metadata:
annotations:
{{ setResourceNameAnnotation "queue-policy" }}
spec:
forProvider:
region: {{ .observed.composite.resource.spec.region }}
queueUrlRef:
name: {{ .observed.composite.resource.metadata.name }}-queue
policy: |
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "Service": "s3.amazonaws.com" },
"Action": "sqs:SendMessage",
"Resource": "{{ $arn }}"
}
]
}
providerConfigRef:
name: default
kind: ClusterProviderConfig
- step: ready
functionRef:
name: function-auto-readyThree things happen here:
- Bucket: created with the region from the XR. No dependencies.
- Queue: created with
writeConnectionSecretToRefso connection details are automatically published to a Kubernetes Secret. - QueuePolicy: references the queue via
queueUrlRef, and reads the queue’s ARN from Crossplane’s observed state to build the IAM policy document.
The queueUrlRef creates an ordering dependency: the queue must exist before the policy. The ARN read from $.observed.resources uses a nil-guard pattern (default (dict) ... dig ...) because on the first reconcile pass, observed.resources is empty and dig handles missing keys safely.
Where did this model bite you?
The pipeline looks clean on paper. Then you apply your first composition and spend an hour decoding an error message that points at the wrong location. Here are the ones that got me.
The trim-collapse bug. Go templates use {{- (left trim) to eat whitespace before a directive. If you put {{- range }} on its own line between a YAML key and its list value, it eats the newline separating them. The YAML decoder sees queue: - id: foo instead of queue: on one line and - id: foo on the next. The error message points at the parent key, not your template directive. The fix: drop the left trim. Use {{ range }} (no dash) on its own line. The blank line between the key and the first item is valid YAML.5
observed.resources is nil on first reconcile. The first time Crossplane evaluates your composition, no resources exist yet. $.observed.resources is an empty dict, or nil for older function versions. Accessing a key with Go’s index panics. Use dig from Sprig instead:
{{ $obs := default (dict) $.observed.resources }}
{{ $data := dig "queue" (dict) $obs }}
{{ $arn := dig "resource" "status" "atProvider" "arn" "" $data }}Each line is safe at every level. If the queue has not been created yet, $arn is an empty string and the policy document still renders. It just will not include a real ARN until the next reconcile pass.
setResourceNameAnnotation is required. Every composed resource needs {{ setResourceNameAnnotation "key" }} in its metadata annotations. Without it, Crossplane treats the resource as a status update on the XR and never creates it. Also, never set crossplane.io/external-name in your template. The provider manages that automatically. Setting it causes a perpetual diff on every reconcile, and your generation counter climbs until the circuit breaker trips.
These are not bugs. They are the shape of a system where the template is not the product. The pipeline has phases, and referencing resource state across phases requires explicit guards.
Tip: Crossplane provides a crossplane render command that runs your composition pipeline locally against a mock XR, no cluster needed:
crossplane render xr.yaml composition.yaml functions.yaml \
--crossplane-version=v2.3.1The version pin matters: without it, the default CLI rejects v2 XRD schemas. This workflow lets you iterate on template syntax, test nil-guard patterns, and verify output before committing.
What if Go templates are not enough?
The example uses function-go-templating because Go templates are familiar to anyone who has written a Helm chart. But Crossplane v2 supports half a dozen composition functions. They all package as OCI images, install the same way, and plug into the same pipeline.6
-
Patch and Transform (
function-patch-and-transform) ported the v1.x mode to a function. You define YAML resource templates with static field patches from the XR. No conditionals, no loops. Good for small, stable compositions where you just need to copy a few fields. -
Go Templating (
function-go-templating) uses Go templates with the Sprig function library. Full control flow withif,range,with. Best when you need conditionals, iteration over arrays, or the nil-guard patterns above. The tradeoff is verbose syntax and cryptic error messages. -
Python and KCL (
function-python,function-kcl) handle complex data transformation and conditional resource creation. If you would reach for aforloop or alocalsblock in Terraform, reach for a programmatic function here. -
YAML + CEL (
function-kro) takes a different approach: define resources in YAML, wire them with CEL expressions, and let the function resolve dependencies automatically. No templates, no code files. CEL strikes a balance between YAML simplicity and the expressiveness of a full language. -
Utilities (
function-auto-ready,function-extra-resources,function-environment-configs,function-cidr) handle single jobs well: checking readiness, fetching cluster-scoped resources, injecting environment overrides, computing subnet ranges. -
Custom functions through the Go SDK. If none of the published options fit, write your own. Package it as an OCI image and install it with
kubectl apply, same as any other function.
The pipeline architecture makes this powerful. Because each step passes its output to the next, you can mix functions: render with Go templating in step one, inject environment configs in step two, then check readiness with function-auto-ready in step three. Each function does one job, and the pipeline composes them.
What happens when my API changes?
The XRD defines a contract. Contracts change. You will add a field, rename a property, or realize that spec.engine should be an enum. Crossplane supports multiple API versions in a single XRD, so you can make breaking changes without disrupting existing resources.
Schema evolution rules. Additive changes (new optional fields, new enum values) are safe within a version. Removals, renames, type changes, and new required fields need a new version.
Adding a version #
Extend the versions array with the new schema, keeping the old version served:
versions:
- name: v1beta1
served: true
referenceable: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: [region, bucketName]
properties:
region:
type: string
bucketName:
type: string
notificationEvents:
type: array
items:
type: string
- name: v1alpha1
served: true
referenceable: false
schema:
# unchanged from beforeThis adds v1beta1 with an optional notificationEvents field. v1alpha1 stays served but is no longer the storage version. Crossplane converts all stored resources to v1beta1 internally.3
Composition revisions #
Every Composition change creates a CompositionRevision, an immutable snapshot with an incrementing revision number. By default, XRs auto-update to the latest revision. For canary rollouts or rollback, set compositionUpdatePolicy: Manual to pin an XR to a specific revision. When you are ready to roll forward, update the compositionRevisionRef name. No redeploy needed.7
Deprecation flow #
Once all consumers have migrated, set served: false on the old version. Clients must update their apiVersion. The deprecationWarning appears in API responses for anyone still using the old version. Once no resources reference it, remove it from the versions array entirely.
Crossplane’s conversion webhook handles internal storage between versions automatically. Resources written as v1alpha1 are stored as v1beta1 and served back in whichever version the client requests. Field renames between versions are lossy: if you renamed bucketName to name, clients reading the old schema would not find their field. Plan schema changes carefully.
Design principles I learned the hard way #
When I designed my first XRD, I made every field required. I regretted it. A few things I wish I had known:
Required fields sparingly. Once a field is required in a published version, you cannot remove it. Prefer optional with a sensible default. region should default to your primary region, not require it from every team.
Prefer enums over booleans. spec.engine: "postgresql" | "mysql" lets you add values later without a new API version. spec.enableBackups: true locks you in. Booleans are fine for debug toggles. Avoid them for anything that might grow.
Prefer arrays over scalars. If spec.widget: "foo" might become spec.widgets: ["foo", "bar"], start with the array. A single-element array is fine. A breaking version bump is expensive.
Leave room for variants. If you might support MySQL and PostgreSQL under one XR, put engine-specific fields in nested objects toggled by a top-level spec.engine enum. A flat schema forces you to add top-level fields per engine, which gets messy fast.
TL;DR #
Crossplane v2 composition is not a template engine. It is an API framework:
- CompositeResourceDefinition (XRD) defines the schema of your custom API. It registers as a first-class Kubernetes resource.
- Composition uses a function pipeline to decide what managed resources to create when someone uses that API.
- Function ecosystem covers six categories: declarative YAML, Go templates, Python, KCL, YAML+CEL, and custom functions through the SDK. All plug into the same pipeline.
- Key gotchas: the trim-collapse bug (drop
{{-on its own line), nilobserved.resourceson first reconcile (usedig), and requiredsetResourceNameAnnotation. - Test locally with
crossplane render --crossplane-version=v2.3.1before committing. - Design for evolution: required fields sparingly, enums over booleans, arrays over scalars. API versioning and composition revisions let you make breaking changes without disrupting consumers.
Next Up #
The first post showed you why Crossplane is more than Terraform in Kubernetes. This post showed you how composition turns managed resources into custom APIs. The third post in this series takes the next step: Backstage as the developer portal, Crossplane as the provisioning engine, and ArgoCD as the GitOps delivery controller. Three tools, one platform.
Have a good day!