CoursesCrossplaneProvider families & multi-cloud

Provider families & multi-cloud

One platform API, many backends.

Advanced12 min · lesson 10 of 12

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.

providers.yaml
# 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/v1
kind: Provider
metadata:
name: provider-aws-s3
spec:
package: xpkg.upbound.io/upbound/provider-aws-s3:v1.21.1
---
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
name: provider-gcp-storage
spec:
package: xpkg.upbound.io/upbound/provider-gcp-storage:v1.11.4
terminal
kubectl apply -f providers.yaml
kubectl get providers
output
provider.pkg.crossplane.io/provider-aws-s3 created
provider.pkg.crossplane.io/provider-gcp-storage created
NAME INSTALLED HEALTHY PACKAGE AGE
provider-aws-s3 True True xpkg.upbound.io/upbound/provider-aws-s3:v1.21.1 12m
provider-gcp-storage True True xpkg.upbound.io/upbound/provider-gcp-storage:v1.11.4 12m
upbound-provider-family-aws True True xpkg.upbound.io/upbound/provider-family-aws:v1.21.1 12m
upbound-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.

compositions.yaml
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: xbucket-aws
labels:
provider: aws # the selector key a claim matches on
spec:
compositeTypeRef:
apiVersion: platform.acme.io/v1alpha1
kind: XBucket # same abstract contract as the GCP variant
mode: Pipeline
pipeline:
- step: render
functionRef:
name: function-patch-and-transform # pipeline logic: see xp-functions
input:
apiVersion: pt.fn.crossplane.io/v1beta1
kind: Resources
resources:
- name: bucket
base:
apiVersion: s3.aws.upbound.io/v1beta2
kind: Bucket
spec:
forProvider:
region: us-east-1
---
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: xbucket-gcp
labels:
provider: gcp # same XBucket contract, different backend
spec:
compositeTypeRef:
apiVersion: platform.acme.io/v1alpha1
kind: XBucket
mode: Pipeline
pipeline:
- step: render
functionRef:
name: function-patch-and-transform
input:
apiVersion: pt.fn.crossplane.io/v1beta1
kind: Resources
resources:
- name: bucket
base:
apiVersion: storage.gcp.upbound.io/v1beta2
kind: Bucket
spec:
forProvider:
location: US
terminal
kubectl get xrd
kubectl get compositions
output
NAME ESTABLISHED OFFERED AGE
xbuckets.platform.acme.io True True 9m14s
NAME XR-KIND XR-APIVERSION AGE
xbucket-aws XBucket platform.acme.io/v1alpha1 8m43s
xbucket-gcp XBucket platform.acme.io/v1alpha1 8m43s
How a claim resolves to a cloud
1Bucket claim
platform.acme.io, in a team namespace
2compositionSelector
matchLabels: provider = aws or gcp
3Matched Composition
xbucket-aws or xbucket-gcp
4Service provider
provider-aws-s3 or provider-gcp-storage reconciles it
5Cloud object store
S3 bucket or GCS bucket
The Bucket API a developer writes stays identical across clouds. The composition choice, made once when the claim is created, is what routes it to a backend, which is why that choice is worth locking down.

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.

claim.yaml
apiVersion: platform.acme.io/v1alpha1
kind: Bucket # the namespaced claim the XRD offers (see xp-composition)
metadata:
name: reports
namespace: team-a
spec:
parameters:
acl: private
compositionSelector:
matchLabels:
provider: aws # picks the backend when this claim is first created
terminal
kubectl apply -f claim.yaml
kubectl get buckets.platform.acme.io -n team-a
kubectl get xbucket
output
bucket.platform.acme.io/reports created
NAME SYNCED READY CONNECTION-SECRET AGE
reports True True 75s
NAME SYNCED READY COMPOSITION AGE
reports-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.

terminal
kubectl get managed
output
NAME SYNCED READY EXTERNAL-NAME AGE
bucket.s3.aws.upbound.io/reports-x8k2p-mn7q4 True True reports-x8k2p-mn7q4 68s
NAME SYNCED READY EXTERNAL-NAME AGE
bucket.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.

Switching clouds is a teardown, not a toggle
Editing a bound claim's 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.
Quick check
01Your cluster has two Compositions serving XBucket: xbucket-aws forces encryption and blocks public access, while xbucket-lab is a sandbox recipe with no guardrails. A developer who can create Bucket claims in their own namespace wants an unguarded bucket. What actually stops them?
Correct — Guardrails live in the Compositions you publish, and enforcedCompositionRef is the override a claim cannot beat.
Incorrect — The provider controller reconciles exactly what the managed resource specifies; it holds no opinion about your policy.
Incorrect — A claim can bypass label matching entirely by naming a Composition directly with compositionRef.
Incorrect — get managed is read-only inventory; it reports what exists, it does not gate what gets made.
02You apply a manifest with exactly two Provider objects, provider-aws-s3 and provider-gcp-storage, yet kubectl get providers then lists four healthy providers. What are the extra two?
Correct — the lesson explains each slice depends on its family provider, which is why two applies show four.
Incorrect — they are not duplicates; each is a distinct shared family provider.
Incorrect — Crossplane installs nothing you did not request; only your slices' family dependencies appear.
Incorrect — families replaced the monoliths; the extra rows are the thin family providers, not the old giants.
03A 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?
Incorrect — there is no copy step; Composition choice is a provisioning decision, not a data-migration tool.
Correct — the lesson's warning is explicit: the old Bucket is dropped and deleted, a new empty one is created, and nothing is copied.
Incorrect — the edit is accepted; changing compositionRef does repoint the backend, unlike editing the selector on a bound claim, which is ignored.
Incorrect — Crossplane removes the old Bucket from the composite's desired resources and deletes it; the two never coexist.

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.

Related