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.
  • kubectl configured 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:

ObjectWhat 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.
CompositionSays which resources to create for each XR, as a pipeline of functions.
FunctionA 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
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.