Crossplane turns a Kubernetes cluster into a control plane for your platform. You define your own APIs as Kubernetes custom resources, and Crossplane creates and keeps in sync whatever sits behind them: cloud databases and buckets through provider packages, or plain Kubernetes objects. In this tutorial you will install Crossplane v2 with Helm and build a small platform API called WebApp: when a developer creates a WebApp, Crossplane composes a Deployment and a Service for it. Because it uses only Kubernetes resources, you can follow it on any cluster without a cloud account.
Prerequisites
To follow this guide you need:
- A Kubernetes cluster running a currently supported Kubernetes version, for example k3s on a CubePath VPS or a local kind cluster. Crossplane itself needs about 1 GB of free memory.
kubectlconfigured with cluster-admin access to that cluster.- Helm 3 or later installed on the machine where you run
kubectl. - Basic knowledge of Kubernetes manifests and custom resources.
Check that kubectl reaches the cluster before you start:
kubectl get nodes
NAME STATUS ROLES AGE VERSION
k8s-01 Ready control-plane,master 12d v1.33.x
How Crossplane composition works
Crossplane v2 revolves around four objects. You will create each one in this tutorial:
| Object | What it does |
|---|---|
| CompositeResourceDefinition (XRD) | Defines a new API, such as WebApp, and its schema. |
| Composite resource (XR) | An instance of that API, created by a user, for example WebApp/shop. |
| Composition | Says which resources to create for each XR, as a pipeline of functions. |
| Function | A package that runs a composition step, such as function-patch-and-transform. |
In Crossplane v2, composite resources are namespaced by default and can compose any Kubernetes resource. Claims, the separate namespaced object used in Crossplane v1, are no longer needed.
Step 1 - Installing Crossplane with Helm
Add the stable Crossplane chart repository:
helm repo add crossplane-stable https://charts.crossplane.io/stable
helm repo update
Install Crossplane in its own namespace:
helm install crossplane crossplane-stable/crossplane \
--namespace crossplane-system \
--create-namespace \
--wait
Check that both components are running: crossplane is the core controller and crossplane-rbac-manager manages permissions for packages:
kubectl get pods -n crossplane-system
NAME READY STATUS RESTARTS AGE
crossplane-6d67f8cd9d-jkq7m 1/1 Running 0 60s
crossplane-rbac-manager-86d9b5cb45-g2mh8 1/1 Running 0 60s
Crossplane registers its own API groups in the cluster:
kubectl api-resources | grep crossplane.io
The list includes compositeresourcedefinitions, compositions, functions and providers.
Step 2 - Allowing Crossplane to manage Deployments and Services
Crossplane only has permission to manage its own resources. To compose Deployments and Services, give it extra permissions through a ClusterRole with the aggregation label that the Crossplane RBAC manager watches:
nano crossplane-rbac.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: crossplane-compose-webapps
labels:
rbac.crossplane.io/aggregate-to-crossplane: "true"
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["*"]
- apiGroups: [""]
resources: ["services"]
verbs: ["*"]
kubectl apply -f crossplane-rbac.yaml
Step 3 - Installing a composition function
Compositions run as a pipeline of functions. function-patch-and-transform creates resources from YAML templates and copies fields from the XR into them. Install it as a Crossplane package:
nano function.yaml
apiVersion: pkg.crossplane.io/v1
kind: Function
metadata:
name: function-patch-and-transform
spec:
package: xpkg.crossplane.io/crossplane-contrib/function-patch-and-transform:v0.8.2
NoteCheck the function's releases page on GitHub (
crossplane-contrib/function-patch-and-transform) and use the newest tag available.
kubectl apply -f function.yaml
Wait until the package is installed and healthy:
kubectl get functions
NAME INSTALLED HEALTHY PACKAGE AGE
function-patch-and-transform True True xpkg.crossplane.io/crossplane-contrib/function-patch-and-transform:v0.8.2 45s
Step 4 - Defining the WebApp API with an XRD
The XRD declares the API that developers will use. WebApp takes a container image and a replica count, and it is namespaced so each team manages its own apps:
nano xrd.yaml
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: v1alpha1
served: true
referenceable: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
image:
type: string
description: Container image to run.
replicas:
type: integer
default: 1
minimum: 1
maximum: 10
required:
- image
The XRD name must be <plural>.<group>. Apply it:
kubectl apply -f xrd.yaml
Crossplane creates a CustomResourceDefinition for the new API. Check that the XRD is established:
kubectl get xrd
NAME ESTABLISHED OFFERED AGE
webapps.platform.example.org True 20s
Step 5 - Writing the Composition
The Composition tells Crossplane what to create for every WebApp. It has a single pipeline step that runs function-patch-and-transform with two templates, a Deployment and a Service. Patches copy the image and replica count from the XR, and use the XR's name as the app label that links the Service to the Pods:
nano composition.yaml
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: webapp-kubernetes
spec:
compositeTypeRef:
apiVersion: platform.example.org/v1alpha1
kind: WebApp
mode: Pipeline
pipeline:
- step: render-resources
functionRef:
name: 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
selector:
matchLabels:
app: placeholder
template:
metadata:
labels:
app: placeholder
spec:
containers:
- name: app
image: placeholder
ports:
- containerPort: 80
patches:
- type: FromCompositeFieldPath
fromFieldPath: spec.image
toFieldPath: spec.template.spec.containers[0].image
- type: FromCompositeFieldPath
fromFieldPath: spec.replicas
toFieldPath: spec.replicas
- type: FromCompositeFieldPath
fromFieldPath: metadata.name
toFieldPath: spec.selector.matchLabels.app
- type: FromCompositeFieldPath
fromFieldPath: metadata.name
toFieldPath: spec.template.metadata.labels.app
readinessChecks:
- type: None
- name: service
base:
apiVersion: v1
kind: Service
spec:
selector:
app: placeholder
ports:
- port: 80
targetPort: 80
patches:
- type: FromCompositeFieldPath
fromFieldPath: metadata.name
toFieldPath: spec.selector.app
readinessChecks:
- type: None
The readinessChecks of type None tell Crossplane to consider each resource ready as soon as it exists, since Deployments and Services do not report the Ready condition that Crossplane managed resources use.
kubectl apply -f composition.yaml
kubectl get compositions
NAME XR-KIND XR-APIVERSION AGE
webapp-kubernetes WebApp platform.example.org/v1alpha1 10s
Step 6 - Creating a WebApp
Now act as a developer. Create a namespace and a WebApp that runs two replicas of Nginx:
kubectl create namespace team-shop
nano shop-webapp.yaml
apiVersion: platform.example.org/v1alpha1
kind: WebApp
metadata:
name: shop
namespace: team-shop
spec:
image: nginx:1.27
replicas: 2
kubectl apply -f shop-webapp.yaml
Check the composite resource. SYNCED means the composition ran and READY means all composed resources are ready:
kubectl get webapp -n team-shop
NAME SYNCED READY COMPOSITION AGE
shop True True webapp-kubernetes 30s
Crossplane created the Deployment and the Service in the same namespace, with names generated from the XR:
kubectl get deployments,services,pods -n team-shop
NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/shop-7xk2p 2/2 2 2 35s
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
service/shop-q9dfl ClusterIP 10.43.120.17 <none> 80/TCP 35s
NAME READY STATUS RESTARTS AGE
pod/shop-7xk2p-6c9b8d7f5d-4lzvt 1/1 Running 0 35s
pod/shop-7xk2p-6c9b8d7f5d-rn2wq 1/1 Running 0 35s
Test the Service by forwarding a local port to it, using the generated Service name from your output:
kubectl port-forward -n team-shop service/shop-q9dfl 8080:80
In a second terminal:
curl -s http://localhost:8080 | grep title
<title>Welcome to nginx!</title>
Step 7 - Watching Crossplane reconcile changes
Crossplane continuously reconciles the composed resources with the XR. Scale the app by changing the XR, not the Deployment:
kubectl patch webapp shop -n team-shop --type merge -p '{"spec":{"replicas":3}}'
kubectl get deployments -n team-shop
NAME READY UP-TO-DATE AVAILABLE AGE
shop-7xk2p 3/3 3 3 2m
If someone edits the Deployment directly, Crossplane puts it back to the state defined by the XR on its next reconcile. Deleting the XR deletes everything it composed:
kubectl delete webapp shop -n team-shop
kubectl get deployments,services -n team-shop
No resources found in team-shop namespace.
Troubleshooting
The WebApp stays SYNCED False. Describe it and read the conditions and events at the bottom:
kubectl describe webapp shop -n team-shop
A message like cannot apply composed resource ... forbidden means Step 2 was skipped or the ClusterRole lacks a resource. An error about the function means it is not installed or not healthy yet.
The function is not healthy. Check the package and the events in crossplane-system:
kubectl describe function function-patch-and-transform
kubectl get events -n crossplane-system --sort-by=.lastTimestamp
Image pull errors usually mean a wrong tag in function.yaml.
Patches do not seem to apply. Read the Crossplane controller logs, which report errors from each pipeline step:
kubectl logs -n crossplane-system deployment/crossplane --tail=100
A typo in fromFieldPath or toFieldPath is the most common cause.
Conclusion
You installed Crossplane v2, defined a namespaced WebApp API with an XRD, and wrote a Composition that turns each WebApp into a Deployment and a Service that Crossplane keeps in sync. The same pattern scales to real infrastructure: install a provider package (for AWS, Google Cloud, Azure and many other services), add its managed resources to your Composition, and store the XRDs and Compositions in Git so a GitOps tool such as Argo CD or Flux applies them to the cluster.
