XRDs & self-service claims
Publish a platform API developers claim.
A good coffee shop hands you a menu, not the keys to the espresso machine. You point at "large oat latte," and someone who knows the machine makes it the right way every time. You never touch the grinder, the steam wand, or the beans. In Crossplane, the menu is a Composite Resource Definition (XRD), the printed list of things people are allowed to order and exactly how each order must be written down. The order slip a developer fills in is called a claim. The machine behind the counter, the composite resource and its composition, is where the real work happens, safely out of the customer's reach.
An XRD (Composite Resource Definition) is the schema for one of your platform's own APIs. An API (Application Programming Interface) is the fixed set of requests a system agrees to accept, the menu again: "large oat latte" is on it, "let me behind the counter" is not. The XRD says what the type is called, which fields it has, what values those fields accept, and which of them are required. Apply an XRD and Crossplane does two things at once. It creates the composite type, the XR (Composite Resource), which is the cluster-wide object your platform team owns and manages (a cluster is the whole pool of machines Kubernetes runs as one system). And, if you ask for it, it creates a claim type: a namespaced, stripped-down version that developers create inside their own namespace (a namespace is a walled-off section of that cluster belonging to one team, like a team's own labeled shelf). The claim is the front door. The XR and the composition are the plumbing behind the wall.
Publishing the API
Here is the whole menu, written as one file. Read it top to bottom the way Crossplane does. The group and names describe the cluster-scoped XR. The claimNames block is what turns on the namespaced claim; leave it out and you get an XR with no developer-facing front door at all. Each entry under versions carries the actual schema, written in OpenAPI (a standard, structured way to describe the shape and allowed values of an API; it is the same schema language Kubernetes publishes for its own built-in objects like a Pod or a Deployment).
apiVersion: apiextensions.crossplane.io/v1kind: CompositeResourceDefinitionmetadata:# the name MUST be <plural>.<group>, or the apply is rejectedname: xpostgresinstances.acme.iospec:group: acme.ionames:kind: XPostgresInstance # the cluster-scoped composite (platform owns it)plural: xpostgresinstancesclaimNames:kind: PostgresInstance # the namespaced claim (developers create this)plural: postgresinstancesdefaultCompositionRef:name: postgres-aws # which composition fulfills the claim by defaultversions:- name: v1alpha1served: true # the API server serves this versionreferenceable: true # compositions are allowed to target this versionschema:openAPIV3Schema:type: objectproperties:spec:type: objectproperties:size:type: stringdescription: "Instance size. One of: small, large."enum: [small, large] # the only vocabulary a developer getsrequired:- size
Two flags on the version matter. served: true means the Kubernetes API server will accept and hand back objects of this version. referenceable: true marks the version that compositions may point at, and exactly one version must carry it. Notice how little schema you actually write. You define size and nothing else, yet the finished claim will also accept fields like compositionRef and writeConnectionSecretToRef. Crossplane injects those standard fields for you, so you only ever spell out the knobs that are specific to your API.
kubectl apply -f xrd.yamlkubectl get xrd
compositeresourcedefinition.apiextensions.crossplane.io/xpostgresinstances.acme.io createdNAME ESTABLISHED OFFERED AGExpostgresinstances.acme.io True True 12s
Those two columns are your confirmation. ESTABLISHED goes True when the composite (XR) type is live in the API. OFFERED goes True when the claim type is live too, which only happens because you set claimNames. Behind that one apply, a single XRD has minted two Custom Resource Definitions (CRDs, the objects that register a new kind with Kubernetes): the cluster-scoped one and the namespaced one. You can list them the same way you would list any other resource type.
kubectl get crd | grep acme.io
postgresinstances.acme.io 2026-07-21T10:14:03Zxpostgresinstances.acme.io 2026-07-21T10:14:03Z
The self-service front door
Now the developer orders. Their entire interaction with your platform is this one small file, dropped into their own namespace. No cloud console, no access keys, no thousand-line module they have to understand. They pick a size, name a Secret (Kubernetes' built-in object for holding a credential such as a password or connection string) to receive the connection details, and apply.
# what a developer writes, in their own namespaceapiVersion: acme.io/v1alpha1kind: PostgresInstancemetadata:name: orders-dbnamespace: team-ordersspec:size: smallwriteConnectionSecretToRef:name: orders-db-conn # a Secret Crossplane creates here, in team-orders
kubectl apply -f claim.yamlkubectl get postgresinstance -n team-orders
postgresinstance.acme.io/orders-db createdNAME SYNCED READY CONNECTION-SECRET AGEorders-db True True orders-db-conn 3m12s
Two columns tell you where the order stands. SYNCED=True means Crossplane accepted your claim and reconciled it. READY=True means the database underneath actually exists and is usable. In the first minutes you will often see SYNCED=True with READY=False, and that is normal: the request was valid, the cloud is still building. Gate an application's rollout on READY, never on SYNCED alone.
That tiny file kicked off a lot. Crossplane created a matching XR one level up (cluster-scoped, named after the claim with a short random suffix), picked the composition, and the composition built the real database: the instance, its network, encryption at rest, backups, every setting you decided the developer should never have to think about. The claim and its XR keep pointers to each other, the claim's spec.resourceRef names its XR and the XR's spec.claimRef names the claim, which is how a namespaced order and a cluster-scoped machine stay tied together. When the database reports ready, the connection details land in the orders-db-conn Secret, right there in team-orders, for an app to mount.
# the cluster-scoped XR that the claim created (developers never touch this)kubectl get xpostgresinstance# the connection Secret, sitting in the developer's own namespacekubectl get secret orders-db-conn -n team-orders
NAME SYNCED READY COMPOSITION AGEorders-db-7q2xk True True postgres-aws 3m20sNAME TYPE DATA AGEorders-db-conn connection.crossplane.io/v1alpha1 4 3m20s
The schema is your policy surface
Here is the part that turns an XRD from a convenience into a security control. Every field you put on the claim is a field a developer can set to anything the schema allows. That cuts both ways. Expose a tidy size: small or large, and the worst a careless developer can do is ask for the wrong size. Expose a raw publiclyAccessible boolean or a free-text cidrBlock, and the same careless developer, or an attacker who has landed a foothold in that namespace, can open your database to the whole internet with one line of YAML (the plain-text format Kubernetes objects are written in) that passes validation cleanly. The XRD schema is the fence. A loose schema is a fence with the gate left open.
You tighten the fence with ordinary OpenAPI validation plus Kubernetes' own admission checks (extra rules the API server runs the instant an object is submitted, before it is written to storage). enum limits a field to a fixed list. pattern forces a string to match a regular expression. minimum and maximum bound a number. And x-kubernetes-validations lets you write a CEL rule (Common Expression Language, a small expression language the API server evaluates at that same admission moment) for things a plain schema cannot say, such as "this field can never change once it is set." Every one of these runs before the object is stored, so a bad claim is rejected the moment you run kubectl apply (kubectl is the Kubernetes command-line tool you have seen in every terminal block above), not discovered hours later in a cloud console.
# under spec.properties in the XRD schema: tighten every field you exposesize:type: stringenum: [small, large]storageGB:type: integerminimum: 20 # no absurdly small or oversized volumesmaximum: 500region:type: stringenum: [eu-west-1, eu-central-1] # only regions you actually operate inx-kubernetes-validations:- rule: "self == oldSelf" # region is immutable once it is setmessage: "region cannot be changed after creation"
Before you publish, read your own API the way a developer will, using kubectl explain. It prints exactly which fields the claim accepts, which are required, and the descriptions you wrote into the schema. This is your last check that you are not handing out a knob you meant to keep to yourself.
kubectl explain postgresinstance.spec
GROUP: acme.ioKIND: PostgresInstanceVERSION: v1alpha1FIELD: spec <Object>DESCRIPTION:<no description>FIELDS:compositionRef <Object>compositionRevisionRef <Object>compositionRevisionSelector <Object>compositionSelector <Object>compositionUpdatePolicy <string>publishConnectionDetailsTo <Object>resourceRef <Object>size <string> -required-Instance size. One of: small, large.writeConnectionSecretToRef <Object>
The fields you did not write, compositionRef, resourceRef, and the rest, are Crossplane's standard plumbing, injected into every claim. size is the only line of vocabulary you invented, and it is marked -required-, exactly as the schema promised. If a field you meant to hide ever shows up in this list, it is reachable, and you fix the XRD before a single claim is filed against it.
Who is allowed to order
Schema decides what can be asked for. Who is allowed to ask is a separate question, and RBAC (Role-Based Access Control, Kubernetes' permission system) answers it, working like the badge reader on each door: it does not care what you want, only whether your badge opens this one. Schema and RBAC are two different fences, and you need both. The design that makes self-service safe is narrow: developers get permission to create the namespaced claim type inside their own namespace, and nothing else. They cannot touch the cluster-scoped XR, the composition, the XRD, or the managed resources out in the cloud. Give a team exactly the claim, in exactly their namespace, and the blast radius of a stolen developer token stops at the edge of that namespace.
apiVersion: rbac.authorization.k8s.io/v1kind: Rolemetadata:name: postgres-claimantnamespace: team-orders # scoped to one namespace onlyrules:- apiGroups: ["acme.io"]resources: ["postgresinstances"] # the claim type, not the XRverbs: ["get", "list", "watch", "create", "update", "patch", "delete"]---apiVersion: rbac.authorization.k8s.io/v1kind: RoleBindingmetadata:name: postgres-claimantnamespace: team-orderssubjects:- kind: Groupname: team-orders-devsapiGroup: rbac.authorization.k8s.ioroleRef:kind: Rolename: postgres-claimantapiGroup: rbac.authorization.k8s.io
If you are on Crossplane v2
Crossplane v2, released in 2025, reshapes this model. Composite resources can now live inside a namespace, and claims become a compatibility feature rather than the default path. The XRD gains a scope field (with values like Namespaced, Cluster, and LegacyCluster) that picks which shape you get. On a v2 platform you often publish a namespaced XR and let developers create that directly, with no separate claim type at all. What you just learned does not change: a small, validated API in the developer's own namespace, with the messy machinery hidden behind it and RBAC scoped tight. Claims still work, plenty of running platforms depend on them, and almost every XRD you meet in the field will be one shape or the other, so both are worth understanding.
versions list can hold more than one entry. Setting referenceable: true on exactly one of them controls what?served: true; referenceable is a separate flag.referenceable: true marks the composition-targetable version, and the lesson notes exactly one version must have it.defaultCompositionRef picks a composition, not an API version.claimNames turns the claim on, independently of referenceable.PostgresInstance claim, kubectl get postgresinstance shows SYNCED=True but READY=False, and an app is waiting on the database. What is the correct read, and what should gate the app's rollout?Before an XRD reaches a real cluster, prove the fence holds. Apply it to a staging control plane (a throwaway, non-production Crossplane cluster you can afford to break), then test the boundary while impersonating a service account (the non-human identity a running app signs in as, rather than a person). Running kubectl auth can-i create postgresinstances -n team-orders --as=system:serviceaccount:team-orders:app should answer yes, and kubectl auth can-i get xpostgresinstances --as=system:serviceaccount:team-orders:app should answer no. If a low-privilege token can reach the composite, the XR, or the cloud beneath it, the leak is in your RBAC, and you fix it there, on staging, where the only thing at risk is a throwaway test database.
Try this
Run kubectl apply -f xrd.yaml on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.
Takeaway
The trap worth remembering here: do not surface dangerous knobs on a claim. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.