Provider families & multi-cloud
One platform API, many backends.
A power adapter for international travel has one job. You push your laptop's plug into it, and whatever the wall socket looks like in Tokyo, London, or New York, electricity comes out the other side. The socket's shape is somebody else's problem. Multi-cloud Crossplane works the same way. You publish one platform API (Application Programming Interface, the single contract other teams write against), a Bucket, and the developer who asks for it never learns whether the object storage underneath lands on Amazon S3 (Simple Storage Service, the object store run by AWS, short for Amazon Web Services), Google Cloud Storage (GCS), or Azure Blob. Same socket, different wiring behind the wall. This lesson is about how that wiring gets built, and why the choice that picks the backend is a control you want to keep your hands on.
Provider families, not monoliths
Buying a single box of screws should not mean carrying home the whole hardware store. The first Crossplane providers worked like that store. provider-aws shipped as one enormous package that carried a CRD (Custom Resource Definition, the schema that teaches the Kubernetes API server about a new kind of object) for every AWS service at once, close to a thousand of them, and your API server had to load the entire pile even when all you wanted was one storage bucket. Upbound, the company behind these providers, broke the monolith into families. Now you install provider-aws-s3, provider-aws-rds (Relational Database Service), provider-gcp-storage, one thin slice per service. Each slice registers only its own CRDs, and each quietly pulls in a shared family provider (provider-family-aws, provider-family-gcp) that owns the common ProviderConfig type, the object that says how to log in to that cloud (see xp-providerconfig). Every family lives in its own API group, s3.aws.upbound.io for the AWS bucket and storage.gcp.upbound.io for the Google one, so two resources both called Bucket never collide.
For a defender, the payoff runs deeper than a smaller install. Every provider you run is a controller pod (a long-lived program in your cluster) holding credentials to a real cloud account. Fewer providers means fewer of those controllers with standing access, and a smaller blast radius (less that an attacker reaches if one pod is popped) should the control plane ever be compromised. So you install the two slices your object-store API needs and nothing else, instead of handing one giant controller the keys to every service in the account.
# Install only the service slices you need. Each one automatically pulls in# its shared family provider (provider-family-aws or provider-family-gcp),# which owns the ProviderConfig type for that cloud (see xp-providerconfig).apiVersion: pkg.crossplane.io/v1kind: Providermetadata:name: provider-aws-s3spec:package: xpkg.upbound.io/upbound/provider-aws-s3:v1.21.1---apiVersion: pkg.crossplane.io/v1kind: Providermetadata:name: provider-gcp-storagespec:package: xpkg.upbound.io/upbound/provider-gcp-storage:v1.11.4
kubectl apply -f providers.yamlkubectl get providers
provider.pkg.crossplane.io/provider-aws-s3 createdprovider.pkg.crossplane.io/provider-gcp-storage createdNAME INSTALLED HEALTHY PACKAGE AGEprovider-aws-s3 True True xpkg.upbound.io/upbound/provider-aws-s3:v1.21.1 12mprovider-gcp-storage True True xpkg.upbound.io/upbound/provider-gcp-storage:v1.11.4 12mupbound-provider-family-aws True True xpkg.upbound.io/upbound/provider-family-aws:v1.21.1 12mupbound-provider-family-gcp True True xpkg.upbound.io/upbound/provider-family-gcp:v1.11.4 12m
You applied two providers and kubectl get providers shows four. The two upbound-provider-family-* rows are the dependencies your slices pulled in for you, each pinned to the exact version its slice needs. Wait until INSTALLED and HEALTHY both read True before you go further. A provider stuck on HEALTHY=False usually means its image is still pulling or its ProviderConfig has not been created yet. At audit time, the objects to trace are the ProviderConfigs, a type the family providers define: each one names a cloud credential, and every service slice that references it can act in that cloud.
One contract, many recipes
The abstract API gets built once. An XRD (CompositeResourceDefinition, the object that declares your platform's own resource type and the namespaced claim developers file against it) defines an XBucket and offers a Bucket claim, and a Composition turns that claim into real infrastructure (both covered in xp-composition). Multi-cloud lives entirely in the Compositions. You write one per backend. Both point at the same compositeTypeRef, both satisfy the identical XBucket contract, and you tag each with a label naming the cloud it targets. The AWS recipe renders an s3.aws.upbound.io Bucket; the GCP (Google Cloud Platform) recipe renders a storage.gcp.upbound.io one. Crossplane lets many Compositions serve one composite type, and the label is what lets a claim pick one later without touching a line of the recipe.
This is also where your guardrails live. Whatever the AWS recipe bakes in, server-side encryption, a blocked public-access setting, an approved region, becomes non-negotiable for every claim that resolves to it. The set of Compositions installed on the cluster is the menu of what a developer is allowed to order. Keep an unguarded recipe off the menu and nobody can order it.
apiVersion: apiextensions.crossplane.io/v1kind: Compositionmetadata:name: xbucket-awslabels:provider: aws # the selector key a claim matches onspec:compositeTypeRef:apiVersion: platform.acme.io/v1alpha1kind: XBucket # same abstract contract as the GCP variantmode: Pipelinepipeline:- step: renderfunctionRef:name: function-patch-and-transform # pipeline logic: see xp-functionsinput:apiVersion: pt.fn.crossplane.io/v1beta1kind: Resourcesresources:- name: bucketbase:apiVersion: s3.aws.upbound.io/v1beta2kind: Bucketspec:forProvider:region: us-east-1---apiVersion: apiextensions.crossplane.io/v1kind: Compositionmetadata:name: xbucket-gcplabels:provider: gcp # same XBucket contract, different backendspec:compositeTypeRef:apiVersion: platform.acme.io/v1alpha1kind: XBucketmode: Pipelinepipeline:- step: renderfunctionRef:name: function-patch-and-transforminput:apiVersion: pt.fn.crossplane.io/v1beta1kind: Resourcesresources:- name: bucketbase:apiVersion: storage.gcp.upbound.io/v1beta2kind: Bucketspec:forProvider:location: US
kubectl get xrdkubectl get compositions
NAME ESTABLISHED OFFERED AGExbuckets.platform.acme.io True True 9m14sNAME XR-KIND XR-APIVERSION AGExbucket-aws XBucket platform.acme.io/v1alpha1 8m43sxbucket-gcp XBucket platform.acme.io/v1alpha1 8m43s
Let the claim pick the cloud
A claim picks its recipe through spec.compositionSelector.matchLabels. Create the claim with provider: aws and Crossplane resolves it to the AWS-labeled Composition, then writes that decision into the claim's compositionRef. That is the multi-cloud switch, and it is thrown once. Here is the part people trip on: the selector only fills a compositionRef that is still empty, so editing the label on a claim that is already bound moves nothing (the warning below spells out why). Developers stay inside one namespaced Bucket API in their own namespace, and the platform team decides which backends are on the menu by shipping Compositions with the right labels. Two more levers sit in the XRD. defaultCompositionRef pins a house default for a claim that names neither a selector nor a reference. enforcedCompositionRef overrides whatever the claim asks and forces one Composition on every reconcile, which is how you nail every Bucket to a single blessed cloud when policy demands it.
apiVersion: platform.acme.io/v1alpha1kind: Bucket # the namespaced claim the XRD offers (see xp-composition)metadata:name: reportsnamespace: team-aspec:parameters:acl: privatecompositionSelector:matchLabels:provider: aws # picks the backend when this claim is first created
kubectl apply -f claim.yamlkubectl get buckets.platform.acme.io -n team-akubectl get xbucket
bucket.platform.acme.io/reports createdNAME SYNCED READY CONNECTION-SECRET AGEreports True True 75sNAME SYNCED READY COMPOSITION AGEreports-x8k2p True True xbucket-aws 75s
Notice the fully-qualified buckets.platform.acme.io in that query. Three kinds on this cluster now answer to the name bucket: your platform claim, plus the S3 and GCS bucket types the two families registered. Ask kubectl for the bare name and it picks one for you by discovery order, which is a fine way to read the wrong object during an incident. Spell out the API group and you always get the claim. The object behind it, reports-x8k2p, is the cluster-scoped XBucket that Crossplane created to back the namespaced claim, and its COMPOSITION column is the receipt: xbucket-aws won, so this bucket lives on S3.
One pane over every cloud
Here is the operator's payoff, and it reads like a single stock-take across every warehouse you rent. Every managed resource (one real cloud object, like a single S3 bucket, that a Crossplane controller keeps reconciled, meaning it keeps the cloud matching what you declared) belongs to the managed category. So one command inventories everything the control plane has provisioned, across every provider and every cloud, in a single list. A second team running a gcp claim next door in namespace team-b shows up in the same output as your AWS one. That is your audit surface. Run it to catch a bucket in a region nobody approved, an orphan left behind after a claim was deleted, or drift between what a Composition asked for and what the cloud actually holds.
kubectl get managed
NAME SYNCED READY EXTERNAL-NAME AGEbucket.s3.aws.upbound.io/reports-x8k2p-mn7q4 True True reports-x8k2p-mn7q4 68sNAME SYNCED READY EXTERNAL-NAME AGEbucket.storage.gcp.upbound.io/logs-7bd4n-k2p9s True True logs-7bd4n-k2p9s 6m12s
Because the choice of Composition decides which guardrails apply, it is exactly what an attacker or a careless teammate reaches for. A claim's author makes that choice, through the selector or by naming a Composition outright with compositionRef, and a direct reference wins over any label. Publish a lab Composition with encryption switched off and a developer can point a new claim straight at it, no cloud console required. The fix has two halves: ship only Compositions that meet your baseline, and reach for enforcedCompositionRef when a namespace cannot be trusted to choose at all. An admission policy (a rule checked before the object is saved, written in Kyverno or as a Kubernetes ValidatingAdmissionPolicy) that constrains spec.compositionSelector and spec.compositionRef closes the same gap from the other direction.
compositionSelector from aws to gcp feels like a toggle you can flip back and forth, but it does nothing. Once the claim exists its compositionRef is already set, and the selector only fills a reference that is still empty, so the label edit is ignored. To actually repoint the claim you change compositionRef directly (or set enforcedCompositionRef on the XRD), and that is where the damage waits. Crossplane drops the old cloud's Bucket from the composite's desired resources, deletes it and everything inside, then creates a fresh empty one on the other cloud. There is no copy step. Composition choice is a provisioning decision, not a data-migration tool. Treat the backend as fixed for the life of a stateful claim: to move data, stand up a new claim beside the old one, replicate at the application or storage layer, then retire the original.Provider objects, provider-aws-s3 and provider-gcp-storage, yet kubectl get providers then lists four healthy providers. What are the extra two?reports bucket claim has served production on AWS for months. To cut costs a teammate edits the bound claim and changes its compositionRef to the GCP recipe. What happens to the data?compositionRef does repoint the backend, unlike editing the selector on a bound claim, which is ignored.The habit that saves you: after you create a claim or change its Composition, run kubectl get xbucket and read the COMPOSITION column before you trust anything. It names the recipe that actually won, which is the same as telling you which cloud your data now sits on and which guardrails wrap it. And if you edited a selector but that column did not move, the sticky compositionRef is telling you the backend was locked the moment the claim was born.
Try this
Run kubectl apply -f providers.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: switching clouds is a teardown, not a toggle. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.