Crossplane
Rewritten for this edition in October 2026 for Kubernetes 1.37. Commands checked against the official documentation (Crossplane v2.4.3, provider-kubernetes v1.4.1, function-patch-and-transform v0.8.2).
Crossplane is an open-source add-on that turns a Kubernetes cluster into a control plane for things outside it. You install a provider, and the provider adds managed resources: Kubernetes objects that stand for an S3 bucket, a database or a DNS record. Its controllers then keep the real thing matched to your YAML, and put it back when someone changes it by hand. On top of that sits composition, which lets you define your own API (say, a WebApp or a Database) and the recipe that expands one of those objects into many. The project graduated in the CNCF in October 2025, and the current line is v2.4 (v2.4.3 shipped on 22 October 2026).
Everything below runs on a local kind cluster with no cloud account. The provider is provider-kubernetes, so the "outside" resource you manage is a ConfigMap, and the composition turns one WebApp object into a Deployment and a Service. Swap in a cloud provider later and the workflow doesn't change.
What changed in Crossplane v2#
Crossplane 2.0 reshaped the parts a learner touches first, so a 2023 tutorial will mislead you in several places. Here's what moved, per the project's What's New in v2 page:
- Managed resources are namespaced. A bucket now lives in a namespace like a Deployment does, so ordinary Kubernetes RBAC decides who may create it. The API groups gained an
.m.(for examples3.aws.m.upbound.io). AWS has full namespaced coverage; the docs say Azure, GCP and others are still being converted. Cluster-scoped managed resources still work and are labelled legacy. - Composite resources (XRs) are namespaced by default. The new
apiextensions.crossplane.io/v2XRD API has ascopefield that defaults toNamespaced;Clusteris the other choice. Crossplane's own fields on an XR moved underspec.crossplane. - Claims are gone from new designs. They existed to give namespaced users a handle on cluster-scoped XRs. With namespaced XRs that job disappears, and neither new scope supports claims. Only
LegacyCluster, which you get by staying on the v1 XRD API, keeps them. - A composition can create any Kubernetes resource: a managed resource, a Deployment, or a third-party custom resource such as a CloudNativePG cluster. You'll grant extra RBAC for kinds Crossplane can't already touch.
- Composition always runs functions. Native patch-and-transform (the old
spec.resourceslist in a Composition) was removed; you writemode: Pipelineand call functions. - Also removed:
ControllerConfig(useDeploymentRuntimeConfig), external secret stores, connection details on XRs, and the default package registry. Every package reference must now be fully qualified, registry included. - New: ManagedResourceDefinitions, which let a big provider install only the resource types you activate, and Operations, an alpha way to run one-off or scheduled function pipelines.
Two dates matter if you're running v1 somewhere. The v2.4 release notes say v1.20 reaches end of life when v2.5 ships in November 2026. And the crossplane CLI now lives in its own repository with its own version numbers, so a CLI release no longer accompanies every core release.
The pieces and how they connect#
Every Crossplane term maps to a Kubernetes object you can kubectl get, and the walkthrough meets them in this order.
- Provider: a package that runs a controller pod and installs the managed resource types for one system (AWS S3, GitHub, Kubernetes itself).
- ProviderConfig tells that provider which credentials to use. A
ClusterProviderConfigserves every namespace; aProviderConfigserves its own. - Managed resource (MR): one external thing, written as YAML with a
spec.forProviderblock. - CompositeResourceDefinition (XRD). The schema of your own API, the way a CRD is the schema of any custom resource.
- Composition: the recipe for that API, a pipeline of function steps.
- Function: a package that does the templating inside a pipeline step, in plain YAML patches, Go templates, KCL or Python.
- Composite resource (XR): an instance of your API that a user creates, which Crossplane expands into the resources the Composition describes.
If custom resources and controllers are still hazy, the Kubernetes Operators chapter covers the pattern Crossplane builds on.
Before you start#
You'll need a cluster with at least 2 GB of RAM, kubectl, and Helm 3.2.0 or later. The install guide asks for an actively supported Kubernetes version. A throwaway kind cluster is the right size for this chapter (the KinD Cluster chapter walks through it); a K3s box works the same way.
kind create cluster --name crossplane-lab
kubectl cluster-info --context kind-crossplane-lab
New to charts and repositories? Helm Part 1 explains what the next three commands do.
Install Crossplane with Helm#
Crossplane ships as a Helm chart in its stable repository. Pinning --version keeps the install reproducible; drop it to take whatever is newest.
helm repo add crossplane-stable https://charts.crossplane.io/stable
helm repo update
helm install crossplane \
--namespace crossplane-system \
--create-namespace \
--version 2.4.3 \
crossplane-stable/crossplane
The chart creates two pods in crossplane-system: the core controller and an RBAC manager that grants Crossplane access to the resource types providers install.
kubectl get pods -n crossplane-system
Output looks like this (pod suffixes will differ):
NAME READY STATUS RESTARTS AGE
crossplane-6d67f8cd9d-g2gjw 1/1 Running 0 60s
crossplane-rbac-manager-86d9b5cf9f-2vc4s 1/1 Running 0 60s
The crossplane CLI is optional for this chapter, but its resource trace command is the quickest way to see why something isn't ready. The install script downloads the binary for your platform into the current directory and leaves moving it to you:
curl -sfL "https://cli.crossplane.io/install.sh" | sh
sudo mv crossplane /usr/local/bin
Install a provider#
The official get-started guide uses the AWS S3 provider, which means an AWS account and access keys. To stay free and local, this chapter uses provider-kubernetes, whose managed resource is an Object: any Kubernetes manifest, applied and watched by the provider. It's the same machinery a cloud provider uses, aimed at your own cluster.
The provider needs permission to create things in the cluster. The project's own example gives its service account a fixed name through a DeploymentRuntimeConfig, then binds that account to cluster-admin. Save this as provider-kubernetes.yaml:
apiVersion: pkg.crossplane.io/v1beta1
kind: DeploymentRuntimeConfig
metadata:
name: provider-kubernetes
spec:
serviceAccountTemplate:
metadata:
name: provider-kubernetes
---
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
name: provider-kubernetes
spec:
package: xpkg.crossplane.io/crossplane-contrib/provider-kubernetes:v1.4.1
runtimeConfigRef:
apiVersion: pkg.crossplane.io/v1beta1
kind: DeploymentRuntimeConfig
name: provider-kubernetes
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: provider-kubernetes-cluster-admin
subjects:
- kind: ServiceAccount
name: provider-kubernetes
namespace: crossplane-system
roleRef:
kind: ClusterRole
name: cluster-admin
apiGroup: rbac.authorization.k8s.io
cluster-admin is fine on a lab cluster you'll delete tonight. On anything shared, bind a role that covers only the kinds you plan to manage. Give each package its own service account name, too: the providers docs warn that two packages sharing one name causes constant reconcile loops.
kubectl apply -f provider-kubernetes.yaml
kubectl get providers
Wait until both columns read True. Output looks like this:
NAME INSTALLED HEALTHY PACKAGE AGE
provider-kubernetes True True xpkg.crossplane.io/crossplane-contrib/provider-kubernetes:v1.4.1 50s
Under the hood, Crossplane converted the provider's CRDs into ManagedResourceDefinitions. The Helm chart installs a default activation policy that switches all of them on, which is why Object is usable straight away. On a cluster set up with --set provider.defaultActivations={} you'd have to activate it yourself; kubectl get mrds shows the state of each one.
Tell the provider which credentials to use#
Every provider needs a ProviderConfig, even one that talks to its own cluster. Here the credential source is InjectedIdentity, meaning "use the service account you're running as", which is the account you just bound.
apiVersion: kubernetes.m.crossplane.io/v1alpha1
kind: ProviderConfig
metadata:
name: kubernetes-provider
namespace: default
spec:
credentials:
source: InjectedIdentity
kubectl apply -f providerconfig.yaml
This one is namespaced, so only managed resources in default can use it. With a cloud provider the shape is the same, plus a Secret. The AWS guide stores the key pair with kubectl create secret generic aws-secret --namespace=crossplane-system --from-file=creds=./aws-credentials.ini and points a ClusterProviderConfig at it with source: Secret.
Create your first managed resource#
An Object wraps a full manifest under spec.forProvider.manifest. This one asks the provider to own a ConfigMap. Save it as object.yaml:
apiVersion: kubernetes.m.crossplane.io/v1alpha1
kind: Object
metadata:
name: hello-config
namespace: default
spec:
watch: true
forProvider:
manifest:
apiVersion: v1
kind: ConfigMap
metadata:
name: hello-config
namespace: default
data:
greeting: "managed by Crossplane"
providerConfigRef:
kind: ProviderConfig
name: kubernetes-provider
Two fields are worth a second look. providerConfigRef names the config from the previous step and, in v2, its kind; leave the ref out and the provider looks for one called default. watch: true is an alpha field that makes the provider watch the ConfigMap itself, so it reacts to a change right away. Without it, provider-kubernetes checks for drift on its poll interval, which defaults to ten minutes.
kubectl apply -f object.yaml
kubectl get objects.kubernetes.m.crossplane.io -n default
kubectl get configmap hello-config -n default -o yaml
Output of the first get looks like this:
NAME KIND PROVIDERCONFIG SYNCED READY AGE
hello-config ConfigMap kubernetes-provider True True 20s
SYNCED means the provider's last attempt to apply your spec worked; READY means the external resource exists and is usable. For an Object, the default readiness policy turns READY true once the create succeeds.
Now break it. Delete the ConfigMap behind Crossplane's back and watch:
kubectl delete configmap hello-config -n default
kubectl get configmap hello-config -n default --watch
It comes back. The default management policy gives Crossplane full control, and its Create step says that if the external resource doesn't exist, Crossplane creates it. Change the greeting value in object.yaml and apply again, and the ConfigMap follows. Delete the Object and the ConfigMap goes with it. A real RDS instance or GKE cluster gets the same loop, with a much costlier mistake.
When an MR won't go green, kubectl describe on it is where to start. The Synced condition carries the provider's error message, and the events below it show each reconcile.
Build your own API with composition#
Managed resources alone give you kubectl access to infrastructure. Composition is what lets a platform team hand developers something smaller: one object with the three fields they care about, expanded into whatever the platform requires. You'll build a WebApp that takes an image and a replica count and produces a Deployment plus a Service.
1. Install a composition function#
Functions are packages, like providers. function-patch-and-transform takes plain YAML resource templates and copies fields between them and the XR, which makes it the easiest to read. The official guide pins v0.8.2:
apiVersion: pkg.crossplane.io/v1
kind: Function
metadata:
name: crossplane-contrib-function-patch-and-transform
spec:
package: xpkg.crossplane.io/crossplane-contrib/function-patch-and-transform:v0.8.2
kubectl apply -f function.yaml
kubectl get functions
2. Define the schema with an XRD#
The XRD is the contract your users see. image is required; replicas is optional. The status fields are where Crossplane will copy facts back from the composed resources.
apiVersion: apiextensions.crossplane.io/v2
kind: CompositeResourceDefinition
metadata:
name: webapps.platform.example.org
spec:
scope: Namespaced
group: platform.example.org
names:
kind: WebApp
plural: webapps
versions:
- name: v1
served: true
referenceable: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
image:
description: Container image to run.
type: string
replicas:
description: Number of pods. Defaults to 1.
type: integer
required:
- image
status:
type: object
properties:
replicas:
type: integer
address:
type: string
kubectl apply -f xrd.yaml
kubectl get xrd
Wait for ESTABLISHED to read True. At that point Kubernetes serves a webapps API, but Crossplane has no idea what to build from one yet.
3. Write the Composition#
The Composition binds the WebApp type to a pipeline. This pipeline has one step, and its input is the function's own format: a list of resources, each with a base template and patches.
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: webapp-yaml
spec:
compositeTypeRef:
apiVersion: platform.example.org/v1
kind: WebApp
mode: Pipeline
pipeline:
- step: deployment-and-service
functionRef:
name: crossplane-contrib-function-patch-and-transform
input:
apiVersion: pt.fn.crossplane.io/v1beta1
kind: Resources
resources:
- name: deployment
base:
apiVersion: apps/v1
kind: Deployment
spec:
replicas: 1
template:
spec:
containers:
- name: web
ports:
- containerPort: 80
patches:
- type: FromCompositeFieldPath
fromFieldPath: metadata.name
toFieldPath: metadata.labels[platform.example.org/app]
- type: FromCompositeFieldPath
fromFieldPath: metadata.name
toFieldPath: spec.selector.matchLabels[platform.example.org/app]
- type: FromCompositeFieldPath
fromFieldPath: metadata.name
toFieldPath: spec.template.metadata.labels[platform.example.org/app]
- type: FromCompositeFieldPath
fromFieldPath: spec.image
toFieldPath: spec.template.spec.containers[0].image
- type: FromCompositeFieldPath
fromFieldPath: spec.replicas
toFieldPath: spec.replicas
- type: ToCompositeFieldPath
fromFieldPath: status.availableReplicas
toFieldPath: status.replicas
readinessChecks:
- type: MatchCondition
matchCondition:
type: Available
status: "True"
- name: service
base:
apiVersion: v1
kind: Service
spec:
ports:
- protocol: TCP
port: 80
targetPort: 80
patches:
- type: FromCompositeFieldPath
fromFieldPath: metadata.name
toFieldPath: metadata.labels[platform.example.org/app]
- type: FromCompositeFieldPath
fromFieldPath: metadata.name
toFieldPath: spec.selector[platform.example.org/app]
- type: ToCompositeFieldPath
fromFieldPath: spec.clusterIP
toFieldPath: status.address
readinessChecks:
- type: NonEmpty
fieldPath: spec.clusterIP
Read the patches as arrows. FromCompositeFieldPath copies from the user's WebApp into the composed resource; ToCompositeFieldPath copies back up into the WebApp's status. Patches are optional by default, so a WebApp without replicas keeps the base value of 1. The readiness checks decide when each composed resource counts as ready, and the WebApp turns ready only when both do.
kubectl apply -f composition.yaml
4. Create a WebApp#
This is the whole interface a developer would see:
apiVersion: platform.example.org/v1
kind: WebApp
metadata:
name: my-site
namespace: default
spec:
image: nginx
kubectl apply -f webapp.yaml
kubectl get webapps -n default
kubectl get deploy,service -n default -l platform.example.org/app=my-site
The first command's output looks like this once the Deployment is available:
NAME SYNCED READY COMPOSITION AGE
my-site True True webapp-yaml 40s
Scale it through your own API, then watch the Deployment follow:
kubectl patch webapp my-site -n default --type merge -p '{"spec":{"replicas":3}}'
kubectl get webapp my-site -n default -o jsonpath='{.status.replicas}'
crossplane resource trace webapp my-site -n default
resource trace prints the XR and everything it composed as a tree, with the ready and synced state of each. It's the first thing to run when a WebApp sits at READY False.
Patch-and-transform is one function of several. The same get-started guide writes this composition in Go templates (function-go-templating), YAML with CEL (function-kro), Python, Pythonic and KCL, so you can pick a language once your compositions need loops or conditionals. With Docker installed, crossplane composition render xr.yaml composition.yaml functions.yaml previews the output on your laptop with no cluster involved.
Common mistakes#
Watch for these on a first Crossplane cluster:
- Following a v1 tutorial. A Composition with
spec.resourcesand no pipeline, aControllerConfig, or a claim against a v2 XRD will be rejected or ignored. Check the date on anything you copy. - A package reference without a registry. A short
crossplane-contrib/provider-kubernetes:v1.4.1leaned on v1's default registry, which v2 removed. Writexpkg.crossplane.io/crossplane-contrib/provider-kubernetes:v1.4.1. - An
Objectstuck atSYNCED Falsewith "forbidden" in its message. The provider is running under a service account the ClusterRoleBinding doesn't name, usually because theDeploymentRuntimeConfigwasn't referenced from the Provider. - Composing a kind Crossplane can't write. Crossplane can create MRs, XRs and some built-in kinds like Deployments. For anything else, add a ClusterRole labelled
rbac.crossplane.io/aggregate-to-crossplane: "true"that grants it. - Editing an XRD schema and seeing no change. The XRD docs say a changed or expanded schema needs a restart of the Crossplane pod.
- Uninstalling in the wrong order. A provider that's gone can't delete what it created, and with a cloud provider that's a bill.
Crossplane or Terraform?#
Both describe infrastructure as code, and the Terraform chapter covers the other one. They differ mainly in when work happens. Terraform acts when someone runs plan and apply, and keeps its record of the world in a state file. Crossplane's controllers run all the time inside a cluster, keep that record as Kubernetes objects, and correct drift on their own. That's the loop you saw when the ConfigMap came back.
The trade-off follows from that. Crossplane needs a cluster that stays up, because nothing reconciles while it's down and nothing gets cleaned up if you remove it first. In return you get Kubernetes RBAC, namespaces, GitOps tooling and your own APIs for free. The two also share more code than you might expect: Upjet, the generator behind the official AWS, Azure and GCP providers, builds Crossplane providers out of Terraform providers. You can browse what's available in the Upbound Marketplace.
Clean up#
The uninstall guide gives the order: composite resources and their XRDs first, then remaining managed resources, then providers, then Crossplane itself.
kubectl delete webapp my-site -n default
kubectl delete composition webapp-yaml
kubectl delete xrd webapps.platform.example.org
kubectl delete function crossplane-contrib-function-patch-and-transform
kubectl delete -f object.yaml
kubectl get managed
kubectl delete -f providerconfig.yaml
kubectl delete -f provider-kubernetes.yaml
helm uninstall crossplane --namespace crossplane-system
kubectl delete namespace crossplane-system
kubectl get managed should come back empty before you remove the provider. Helm leaves Crossplane's CRDs behind; kubectl get crd | grep crossplane.io lists them if you want them gone. On a kind cluster it's quicker to drop the whole thing with kind delete cluster --name crossplane-lab.
Where this fits in the book#
Your XRDs and Compositions are plain YAML, so the natural next step is to keep them in Git and let Argo CD apply them. To understand what the provider pod is doing on every reconcile, the Kubernetes Operators chapter is the pattern behind it. When you're ready to point this at a real cloud, the official get-started guides continue with the AWS S3 provider, and the release notes list what changes in each minor version.
Further watching#
- Video by Anais Urlichs (the original 100 Days of Kubernetes series). It provisions an AKS cluster with the v1-era Azure provider, so the concepts carry over and the commands don't.