Crossplane manages cloud infrastructure through the Kubernetes API. You describe a storage location or a database as an object in the cluster, and a controller makes sure the real resource comes into being at the vendor and stays in exactly that state. The difference to a classic infrastructure tool lies less in the describing than in the operating: there is no run somebody kicks off, there is a control loop that never stops.
This post is a full tutorial and assumes no prior knowledge of Crossplane. It starts at the installation, moves through the first provider, the first managed resource and your own platform API, and ends at the places where it gets uncomfortable: at deletion, at drift, and at the number of resource types a provider writes into the cluster. Everything here is written against Crossplane 2.3, so against the version in which Composite and Managed Resources sit in a namespace by default.
Contents
- The tool shop and the ticket that sits for three days
- What Crossplane is and why it lives in the cluster
- What you need
- Installing Crossplane
- The provider: Crossplane learns object storage
- Credentials and ProviderConfig
- The first managed resource
- What the controller does while you wait
- The test that separates Crossplane from a run
- The goal: your own order card for the shop
- The XRD: defining the order card
- The Composition: what happens behind the order card
- Patches: how the request gets into the resources
- The developer orders their own storage
- Namespaces instead of Claims: what version 2 changed
- Returning status: what the developer should see
- Deleting: the rule that hurts once
- The complete manifests
- The building blocks at a glance
- Crossplane or an infrastructure run?
- Common pitfalls
- When Crossplane pays off, when it does not
- FAQ
- Takeaway
- Sources
The tool shop and the ticket that sits for three days
An online shop sells tools: drills, jigsaws, cordless screwdrivers, plus accessories and spare parts. The shop has long stopped being one application, it is a handful of services. Catalogue, reviews, spare parts search, each with its own team.
Every one of these services needs the same kind of storage. Product images in several sizes, plus assembly instructions as PDF, because whoever buys a jigsaw wants to see the manual in the shop and not find it in the box first. The rules are the same everywhere: versioning on, so an accidentally overwritten image can be fetched back, and from the outside nothing is publicly reachable, because a delivery service sits in front of the files.
The way there goes through a ticket. A team reports the need, the platform team enters a block into the infrastructure configuration, somebody reviews the plan, somebody applies. In the good case that takes a day, in the normal case three, and in the week before a release it takes longer, because then everybody wants something at the same time.
Twice something went wrong along that way, and both times in the same manner. When the storage for the spare parts search was created, versioning was missing, because the copied block came from an older place. And when the colleague who built the storage for the reviews briefly opened up access by hand to check a file, he forgot to close it again. Both were noticed weeks later.
Nobody here was being inattentive. This procedure structurally allows both mistakes: copying without a check, and one manual move between two runs that nobody notices. The rest of this article builds the same storage once more, but in a way both mistakes no longer fit into. At the end the spare parts search team writes eight lines of YAML into its own namespace and gets storage that matches the rules and matches them again even if somebody turns a knob by hand.
What Crossplane is and why it lives in the cluster
Crossplane is an open source project of the Cloud Native Computing Foundation and reached the status “Graduated” there at the end of October 2025, so the highest maturity level. It extends Kubernetes with the ability to manage resources outside the cluster.
The core is an idea Kubernetes already carries anyway. You create an object that describes a desired state, and a controller works incessantly on bringing reality in line with that desire. With a Deployment, reality is a set of running pods. With Crossplane, reality is an object store, a database or a network at the cloud vendor.
From that follows the difference that matters most in practice. An infrastructure run is an event: it begins, it ends, and afterwards nobody looks any more. A controller is a state: it compares desired and actual in a fixed rhythm and corrects what deviates. Whoever changes a managed setting by hand finds it reset at the next reconciliation.
The second difference is access control. Because everything is a Kubernetes object, the rules that apply in the cluster anyway apply here too. Who may order storage and who may not is an RBAC question and not a question of who has access to the infrastructure configuration.
The price for that is just as clear: the cluster itself becomes production infrastructure. It has to be patched, backed up and monitored, also for resources that have nothing to do with Kubernetes. Whoever does not want to pay that price is better served with a run. The section at the end comes back to this.
What you need
For this tutorial you need a Kubernetes cluster on which you have administrator rights. To start with, a local cluster with kind or k3d is enough, three to four gigabytes of memory should be free. On top of that kubectl and helm.
On the vendor side you need credentials with rights on the object storage. The examples here use the AWS provider and its S3 resources, because it is the best documented one. The procedure is identical with other vendors, what changes are the names of the resource types and the fields.
All manifests in this article are written against Crossplane 2.3. The version matters, because the object models changed with version 2 and most guides on the web still show the old model. How you spot that is in the section about namespaces and claims.
Installing Crossplane
Crossplane is installed with Helm and puts its own components into a namespace of their own:
helm repo add crossplane-stable https://charts.crossplane.io/stable
helm repo update
helm install crossplane crossplane-stable/crossplane
--namespace crossplane-system
--create-namespaceAfter that two pods are running, the core and a manager for packages:
kubectl get pods -n crossplane-systemNAME READY STATUS RESTARTS AGE
crossplane-7d4b8f9c56-2xk9p 1/1 Running 0 62s
crossplane-rbac-manager-6c9f7d4b8f-lm4qt 1/1 Running 0 62sMore interesting than the pods is what Crossplane added to the API of the cluster:
kubectl api-resources --api-group=pkg.crossplane.ioNAME SHORTNAMES APIVERSION NAMESPACED KIND
configurations pkg.crossplane.io/v1 false Configuration
functions pkg.crossplane.io/v1 false Function
providers pkg.crossplane.io/v1 false ProviderThese three types are the whole beginning. A Provider brings along the knowledge about a vendor, a Function processes templates, and a Configuration bundles both into a package. At this point Crossplane does not know a single cloud resource.
The provider: Crossplane learns object storage
A Provider is a package that knows the API of a vendor and writes its own Kubernetes resource into the cluster for every resource type. After the installation the cluster can deal with Bucket just as it deals with Deployment.
The first uncomfortable property lurks here, which is why it stands at the beginning and not in the pitfalls. The big, monolithic AWS provider brings along over 900 resource types. Every one of them is a CustomResourceDefinition, and that many at once put the Kubernetes API noticeably under pressure. In documented cases the API server was unreachable for up to an hour during the subsequent scaling of the cluster core.
The answer to that is called Provider Families. Instead of one package for the whole vendor you install one per service, and only what you actually need gets into the cluster:
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
name: provider-aws-s3
spec:
package: xpkg.upbound.io/upbound/provider-aws-s3:v2.6.3Apply and wait until the package is reported as healthy:
kubectl apply -f provider.yaml
kubectl get providersNAME INSTALLED HEALTHY PACKAGE AGE
provider-aws-s3 True True xpkg.upbound.io/upbound/provider-aws-s3:v2.6.3 48sThe provider has now brought along its resource types. Instead of the over 900 from the monolith there are only those of the object storage:
kubectl api-resources --api-group=s3.aws.m.upbound.ioNAME APIVERSION NAMESPACED KIND
buckets s3.aws.m.upbound.io/v1beta1 true Bucket
bucketlifecycleconfigurations s3.aws.m.upbound.io/v1beta1 true BucketLifecycleConfiguration
bucketpublicaccessblocks s3.aws.m.upbound.io/v1beta1 true BucketPublicAccessBlock
bucketversionings s3.aws.m.upbound.io/v1beta1 true BucketVersioningThe output is shortened to the four types this article needs. The package brings along more, for example for policies and replication, but it stays at a good two dozen instead of over 900.
Two things about it matter. The m in s3.aws.m.upbound.io stands for the new, namespace-capable model of Crossplane 2. And the column NAMESPACED says true, so these resources live in a namespace like a Deployment does. If you find a group without the m in a guide, then it describes the old model.
Credentials and ProviderConfig
The provider now knows what an object store looks like, but not in whose account it should create one. For that you need two things: a Secret with the credentials and a configuration that points at it.
First the namespace the team works in. Everything else in this article lives inside it:
kubectl create namespace team-spare-partsThen the credentials. For a tutorial with static keys a small file is enough:
[default]
aws_access_key_id = YOUR_KEY
aws_secret_access_key = YOUR_SECRETkubectl create secret generic aws-access
--namespace team-spare-parts
--from-file=credentials=./credentials.txtFor production use this is the wrong way, and that is not a formality. Static keys sit permanently in the cluster, they can be copied and they do not expire. The better way is called Workload Identity: the provider pod gets a short-lived token that the vendor exchanges for a role, and no key comes into being at all. For the first pass we stay with the simple variant here, the section about pitfalls says what to watch out for when you switch.
Now the configuration. It sits in the same namespace as the resources it is supposed to serve:
apiVersion: aws.m.upbound.io/v1beta1
kind: ProviderConfig
metadata:
name: default
namespace: team-spare-parts
spec:
credentials:
source: Secret
secretRef:
namespace: team-spare-parts
name: aws-access
key: credentialsHere too the namespace is the actual point. A ProviderConfig applies only to resources in the same namespace. If an account is supposed to apply cluster-wide, there is a second type for that, ClusterProviderConfig, which is created without a namespace. That allows a clean separation: the spare parts search team works against a different account than the reviews team, without either team being able to see the other one’s account.
The first managed resource
Now the first storage location, directly and without abstraction. A resource that stands for an object at the vendor is called a Managed Resource:
apiVersion: s3.aws.m.upbound.io/v1beta1
kind: Bucket
metadata:
name: tool-manuals
namespace: team-spare-parts
spec:
forProvider:
region: eu-central-1
providerConfigRef:
kind: ProviderConfig
name: defaultTwo fields carry the whole structure. Under forProvider sits everything the vendor itself knows, so exactly the fields from its API. Everything above that is Crossplane’s business: which configuration applies, what should happen on deletion, where connection data gets written.
Apply and watch:
kubectl apply -f bucket.yaml
kubectl get bucket -n team-spare-parts -wNAME SYNCED READY EXTERNAL-NAME AGE
tool-manuals False 3s
tool-manuals True False tool-manuals 9s
tool-manuals True True tool-manuals 14sThe two columns SYNCED and READY are the reason this output is here. SYNCED means Crossplane was able to talk to the vendor’s API and the request arrived. READY means the resource is actually usable at the vendor. Between the two lies the time the vendor needs, and with a database or a cluster those are minutes instead of seconds.
If SYNCED stays at False, the reason is almost always with the credentials or the rights. The resource itself tells you where it is stuck:
kubectl describe bucket tool-manuals -n team-spare-partsWhat the controller does while you wait
Up to here this looks like a cumbersome way to create a storage location. The difference only shows afterwards, in continuous operation.
For every managed resource a loop is running. It asks the vendor for the actual state, compares it with what is in the object, and writes the difference back. If it finds no difference, it does nothing and waits for the next pass.
The rhythm of this loop is adjustable and worth a look before you rely on it. The Crossplane core itself checks every minute by default. Providers that were generated from a Terraform provider, and the big cloud providers are among them, check every ten minutes per resource by default. So it is not “immediately”, and the mechanism is no good for alerting. For what it is supposed to do, it is enough: a deviation does not survive permanently.
What matters is the limit of that statement, and it is often drawn too generously. Crossplane only pulls back resources it manages itself, and there only the fields that are in the manifest. A storage location somebody creates by hand in the console stays untouched, because Crossplane knows nothing about it. It is not a guard over the account, it is a guard over its own objects.
The test that separates Crossplane from a run
The access opened by hand from the opening story can be reproduced now.
First the block that closes public access, as a resource of its own:
apiVersion: s3.aws.m.upbound.io/v1beta1
kind: BucketPublicAccessBlock
metadata:
name: tool-manuals-access-block
namespace: team-spare-parts
spec:
forProvider:
region: eu-central-1
bucketRef:
name: tool-manuals
blockPublicAcls: true
blockPublicPolicy: true
ignorePublicAcls: true
restrictPublicBuckets: true
providerConfigRef:
kind: ProviderConfig
name: defaultbucketRef is more than a writing convenience here. Crossplane resolves the reference, enters the real name and thereby also establishes the order: the block is only set once the storage location exists.
Now the manual intervention. Open public access in the vendor’s console or with its command line, the way the colleague in the opening story did. Then wait out the rhythm and look there again: the block is closed.
The proof belongs in the vendor’s console and explicitly not in the cluster. In the cluster nothing moved, and that can be shown too:
kubectl get bucketpublicaccessblock -n team-spare-partsSYNCED and READY stood at True the whole time, because the desired state was never gone. What got corrected was at the vendor, not on the object.
Nobody started a run, nobody noticed the intervention, and still the state is back where it should be. That is the point where the model pays off, and it is also the point where it can get uncomfortable: whoever deliberately intervenes by hand during an incident has to know that the controller will take it out of their hands. For such cases there are Management Policies, with which a resource can temporarily be only observed instead of managed.
The goal: your own order card for the shop
Up to here little has changed about the core of the problem. Instead of a block in the infrastructure configuration, somebody now writes three YAML files, and that somebody has to know what a public access block is. For a platform team that is fine, for the spare parts search team it is too much.
For this there is the part of Crossplane that justifies the effort. You define your own resource type that looks the way your company thinks, and you determine what happens behind it. For the shop that type is called ShopStorage, and a team orders like this:
apiVersion: shop.toolshop.example/v1
kind: ShopStorage
metadata:
name: manuals
namespace: team-spare-parts
spec:
region: eu-central-1
retentionDays: 30No more talk of access blocks and versioning. These rules apply because they are in the template, and not because somebody thought of them. The mistake from the opening story, the missing block in the copied section, has no place here any more, because there is nothing left to copy.
Two building blocks belong to this. The CompositeResourceDefinition, XRD for short, describes the order card: what the type is called and which fields it has. The Composition describes what comes into being in response to the order.
The XRD: defining the order card
apiVersion: apiextensions.crossplane.io/v2
kind: CompositeResourceDefinition
metadata:
name: shopstorages.shop.toolshop.example
spec:
scope: Namespaced
group: shop.toolshop.example
names:
kind: ShopStorage
plural: shopstorages
versions:
- name: v1
served: true
referenceable: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
region:
description: Region in which the storage comes into being.
type: string
retentionDays:
description: Days an old image version is kept.
type: integer
default: 30
required:
- region
status:
type: object
properties:
storageName:
description: Name of the storage at the vendor.
type: stringThe schema is an ordinary OpenAPI schema, the same language in which the built-in Kubernetes types are described too. That gets you the validation for free: whoever writes retentionDays: thirty is turned away at apply time and not only when a vendor API complains.
The decisive field is scope: Namespaced. It is the default in version 2 and makes sure the order card sits in a namespace and only creates resources in the same namespace. The separation between the teams no longer depends on an agreement, it sits in the object.
Apply and check whether Crossplane accepted the new type:
kubectl apply -f xrd.yaml
kubectl get xrdNAME ESTABLISHED OFFERED AGE
shopstorages.shop.toolshop.example True 6sESTABLISHED at True means the cluster knows ShopStorage from now on, the way it knows Deployment.
The Composition: what happens behind the order card
The Composition is the template. It says which managed resources come into being when somebody orders a ShopStorage.
Since version 2 a Composition is a series of functions that run one after another and build up the list of resources to be created as they go. The earlier procedure, in which the Composition assembled the fields directly itself, has been deprecated since 1.17. The most common function for getting started is function-patch-and-transform, and it has to be installed like a provider:
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.2With that the template is in place:
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: shopstorage-s3
spec:
compositeTypeRef:
apiVersion: shop.toolshop.example/v1
kind: ShopStorage
mode: Pipeline
pipeline:
- step: build-resources
functionRef:
name: function-patch-and-transform
input:
apiVersion: pt.fn.crossplane.io/v1beta1
kind: Resources
resources:
- name: storage
base:
apiVersion: s3.aws.m.upbound.io/v1beta1
kind: Bucket
spec:
forProvider: {}
providerConfigRef:
kind: ProviderConfig
name: default
- name: versioning
base:
apiVersion: s3.aws.m.upbound.io/v1beta1
kind: BucketVersioning
spec:
forProvider:
bucketSelector:
matchControllerRef: true
versioningConfiguration:
status: Enabled
providerConfigRef:
kind: ProviderConfig
name: default
- name: access-block
base:
apiVersion: s3.aws.m.upbound.io/v1beta1
kind: BucketPublicAccessBlock
spec:
forProvider:
bucketSelector:
matchControllerRef: true
blockPublicAcls: true
blockPublicPolicy: true
ignorePublicAcls: true
restrictPublicBuckets: true
providerConfigRef:
kind: ProviderConfig
name: defaultOne detail in there is the actual trick. With the first resource there was still bucketRef with a fixed name, here there is bucketSelector with matchControllerRef: true. A fixed name no longer works, because the template does not know what the storage will be called. Instead the selector says: take the storage that belongs to the same order as I do. That way the same template works for every team and every order.
What is still missing is the connection between order card and resources. So far the region is nowhere.
Patches: how the request gets into the resources
A patch copies a value from the order card into the created resource. With the storage there are two of them: the region and the retention period, and the second one takes a different shape along the way.
The simple case, straight into the entry for the storage:
- name: storage
base:
apiVersion: s3.aws.m.upbound.io/v1beta1
kind: Bucket
spec:
forProvider: {}
providerConfigRef:
kind: ProviderConfig
name: default
patches:
- type: FromCompositeFieldPath
fromFieldPath: spec.region
toFieldPath: spec.forProvider.region
- type: ToCompositeFieldPath
fromFieldPath: metadata.annotations[crossplane.io/external-name]
toFieldPath: status.storageNameThe first patch runs from the order card into the resource, the second one back. The way back is the one teams use most: it enters the name under which the storage actually exists at the vendor into the status of the order card. Without it every team would have to search through the created resources to find out what its storage location is called.
The second case is the more interesting one, because the value has to change along the way. The order card names a number of days, the vendor’s cleanup rule expects a nested structure:
- name: cleanup
base:
apiVersion: s3.aws.m.upbound.io/v1beta1
kind: BucketLifecycleConfiguration
spec:
forProvider:
bucketSelector:
matchControllerRef: true
rule:
- id: old-image-versions
status: Enabled
noncurrentVersionExpiration:
- noncurrentDays: 30
providerConfigRef:
kind: ProviderConfig
name: default
patches:
- type: FromCompositeFieldPath
fromFieldPath: spec.region
toFieldPath: spec.forProvider.region
- type: FromCompositeFieldPath
fromFieldPath: spec.retentionDays
toFieldPath: spec.forProvider.rule[0].noncurrentVersionExpiration[0].noncurrentDaysThe path with the indices looks unwieldy and is still the place where the order card shows its worth. Out of a number a team understands comes the structure the vendor demands here. This translation is what a platform API actually consists of, and somebody has to maintain it.
Versioning, access block and cleanup rule need the same region patch, because region is a mandatory field on every one of these resources. In the complete manifests at the end it is therefore entered everywhere.
The developer orders their own storage
Everything is in place. Now the view all of this was built for:
apiVersion: shop.toolshop.example/v1
kind: ShopStorage
metadata:
name: manuals
namespace: team-spare-parts
spec:
region: eu-central-1
retentionDays: 30kubectl apply -f storage.yaml
kubectl get shopstorage -n team-spare-partsNAME SYNCED READY COMPOSITION AGE
manuals True True shopstorage-s3 41sAnd underneath, what came out of it:
kubectl get managed -n team-spare-partsNAME SYNCED READY AGE
bucket/manuals-x7k2m True True 41s
bucketversioning/manuals-p4n8w True True 38s
bucketpublicaccessblock/manuals-d9 True True 38s
bucketlifecycleconfiguration/manu-q3 True True 37sFour resources out of eight lines, and the three rules somebody previously had to keep in mind by hand are no longer optional. Whoever orders a ShopStorage gets versioning, access block and cleanup rule, whether they know the terms or not.
The way back is just as short. If the order card is deleted, all four resources disappear, because they belong to it.
Namespaces instead of Claims: what version 2 changed
If you follow older guides, at this point you will stumble over a term that does not appear here: the Claim. It is worth placing it once, because most examples on the web still assume it.
In the old model Composite Resources sat in the cluster, without a namespace. So that a team could still order something, there was a second object in the namespace, the Claim, which referred to the actual resource. Two objects for one thing, only so that the separation between teams worked.
In version 2 Composite Resources sit in the namespace themselves, and with that the detour falls away. The new modes Namespaced and Cluster know no Claims any more. Whoever still needs them sets scope: LegacyCluster, which is explicitly the backwards compatibility mode.
The same applies to the managed resources. In version 2 they are at home in the namespace too, recognisable by the m in the API group. The old, cluster-wide variant keeps running, but counts as legacy and is meant to be removed later. So for a new setup there is no reason to start with the old model.
In practice, when reading other people’s examples, that means: if it says kind: XPostgreSQLInstance next to a PostgreSQLInstance Claim, it is the old model. If it says apiextensions.crossplane.io/v2 and scope: Namespaced, it is the new one.
Returning status: what the developer should see
A platform API that only takes things in and gives nothing back is only half an API. The spare parts search team has to find out what its storage is called, otherwise it cannot enter it into the application.
The way back is done by the patch with ToCompositeFieldPath from the previous section. It fills the field declared under status in the XRD:
kubectl get shopstorage manuals -n team-spare-parts -o jsonpath='{.status.storageName}'manuals-x7k2mThe name carries a random suffix, and that is on purpose. Names of object stores are globally unique at many vendors, a fixed name would collide with the second team. Crossplane therefore generates a unique name and writes it into the annotation crossplane.io/external-name. Whoever needs a fixed name sets exactly that annotation themselves.
For credentials there is a separate way. Connection details of a resource, for example user and password of a database, do not end up in the status but in a Secret. The status is readable for everyone who is allowed to see the order card, a secret has no business being there.
Deleting: the rule that hurts once
Deleting is the part guides like to leave out, and the one that does the most damage in practice.
The basic rule is simple: if the order card is deleted, the created resources are deleted too, and with them the objects at the vendor. A storage location with assembly instructions is then gone, contents included.
That is mostly right and sometimes fatal. Which is why every managed resource has a field for it:
spec:
deletionPolicy: OrphanWith Orphan only the Kubernetes object disappears, the object at the vendor stays standing. The default is Delete. For everything that holds data, Orphan in the template is the more cautious choice, because an accidentally deleted storage location cannot be fetched back by applying again.
The second, more uncomfortable case is the one where nothing happens at all any more. If an object gets stuck during deletion, it is almost always down to a Finalizer: Crossplane has issued the delete request at the vendor but gets no confirmation, because the rights are missing or the resource is still occupied. A storage location with contents cannot be deleted at most vendors as long as files are in it. The object then sits at Terminating, and the reason is in the events:
kubectl describe bucket manuals-x7k2m -n team-spare-partsRemoving the Finalizer by hand solves the symptom and leaves the resource behind at the vendor, without anybody knowing about it any more. It is the emergency brake, not the solution.
The complete manifests
Everything together, in the order in which it is applied. Provider, Function and ProviderConfig from the previous sections are assumed.
The order card:
apiVersion: apiextensions.crossplane.io/v2
kind: CompositeResourceDefinition
metadata:
name: shopstorages.shop.toolshop.example
spec:
scope: Namespaced
group: shop.toolshop.example
names:
kind: ShopStorage
plural: shopstorages
versions:
- name: v1
served: true
referenceable: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
region:
description: Region in which the storage comes into being.
type: string
retentionDays:
description: Days an old image version is kept.
type: integer
default: 30
required:
- region
status:
type: object
properties:
storageName:
description: Name of the storage at the vendor.
type: stringThe template:
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: shopstorage-s3
spec:
compositeTypeRef:
apiVersion: shop.toolshop.example/v1
kind: ShopStorage
mode: Pipeline
pipeline:
- step: build-resources
functionRef:
name: function-patch-and-transform
input:
apiVersion: pt.fn.crossplane.io/v1beta1
kind: Resources
resources:
- name: storage
base:
apiVersion: s3.aws.m.upbound.io/v1beta1
kind: Bucket
spec:
forProvider: {}
deletionPolicy: Orphan
providerConfigRef:
kind: ProviderConfig
name: default
patches:
- type: FromCompositeFieldPath
fromFieldPath: spec.region
toFieldPath: spec.forProvider.region
- type: ToCompositeFieldPath
fromFieldPath: metadata.annotations[crossplane.io/external-name]
toFieldPath: status.storageName
- name: versioning
base:
apiVersion: s3.aws.m.upbound.io/v1beta1
kind: BucketVersioning
spec:
forProvider:
bucketSelector:
matchControllerRef: true
versioningConfiguration:
status: Enabled
providerConfigRef:
kind: ProviderConfig
name: default
patches:
- type: FromCompositeFieldPath
fromFieldPath: spec.region
toFieldPath: spec.forProvider.region
- name: access-block
base:
apiVersion: s3.aws.m.upbound.io/v1beta1
kind: BucketPublicAccessBlock
spec:
forProvider:
bucketSelector:
matchControllerRef: true
blockPublicAcls: true
blockPublicPolicy: true
ignorePublicAcls: true
restrictPublicBuckets: true
providerConfigRef:
kind: ProviderConfig
name: default
patches:
- type: FromCompositeFieldPath
fromFieldPath: spec.region
toFieldPath: spec.forProvider.region
- name: cleanup
base:
apiVersion: s3.aws.m.upbound.io/v1beta1
kind: BucketLifecycleConfiguration
spec:
forProvider:
bucketSelector:
matchControllerRef: true
rule:
- id: old-image-versions
status: Enabled
noncurrentVersionExpiration:
- noncurrentDays: 30
providerConfigRef:
kind: ProviderConfig
name: default
patches:
- type: FromCompositeFieldPath
fromFieldPath: spec.region
toFieldPath: spec.forProvider.region
- type: FromCompositeFieldPath
fromFieldPath: spec.retentionDays
toFieldPath: spec.forProvider.rule[0].noncurrentVersionExpiration[0].noncurrentDaysThe order:
apiVersion: shop.toolshop.example/v1
kind: ShopStorage
metadata:
name: manuals
namespace: team-spare-parts
spec:
region: eu-central-1
retentionDays: 30The building blocks at a glance
| Building block | API group | Where it lives | What for |
|---|---|---|---|
| Provider | pkg.crossplane.io/v1 |
cluster-wide | brings along the resource types of a service |
| Function | pkg.crossplane.io/v1 |
cluster-wide | processes the template of a Composition |
| ProviderConfig | <vendor>.m.upbound.io/v1beta1 |
namespace | credentials for resources in the same namespace |
| ClusterProviderConfig | <vendor>.m.upbound.io/v1beta1 |
cluster-wide | credentials for all namespaces |
| Managed Resource | <service>.<vendor>.m.upbound.io/v1beta1 |
namespace | a single object at the vendor |
| CompositeResourceDefinition | apiextensions.crossplane.io/v2 |
cluster-wide | defines your own type and its schema |
| Composition | apiextensions.crossplane.io/v1 |
cluster-wide | template of what comes into being on an order |
| Composite Resource | your own group | namespace | the order itself |
The mixed versions in the table are not a typo. The XRD API jumped to v2 with version 2, because the field scope was added there, the Composition stayed at v1.
Two abbreviations keep meeting you along the way: XRD for CompositeResourceDefinition and XR for Composite Resource.
Crossplane or an infrastructure run?
The question is mostly asked as a tool comparison and is not one. Both describe infrastructure declaratively, both are open source, both can create the same resources. The difference lies in the operating model and in the question of who orders.
A run has a clear beginning and a clear end. That makes it easy to follow, because a human reads the plan before the execution and a log stands there afterwards. For everything that happens rarely and wants to be well considered, that is the fitting form. A network, a cluster, the foundation in other words.
With a controller that moment is missing. Nobody reads a plan beforehand, and in return it carries everything that happens often and is supposed to look the same every time. The storage from this article is that case: always the same thing, ordered by changing people from six teams.
The widespread split therefore follows from the two models. The run lays the foundation, Crossplane delivers on top of it what teams order for themselves. Whoever mixes the two should draw the line deliberately and write it down, because two tools on the same resource end in a conflict nobody resolves any more.
Common pitfalls
- The monolithic provider of a large vendor installs over 900 resource types and can block the API server for a long while. Install one package per service instead of the collected package.
- If the
mis missing from the API group, or a claim shows up, the guide describes Crossplane 1. Much of it still works, but for a new setup you are building in legacy from day one. - A fixed name in
bucketRefdoes not work in a template, because the names only come into existence with the order. The selector withmatchControllerRef: truepicks the sibling resource of the same order. - A
ProviderConfigonly applies inside its own namespace. If it is missing there, the resource stays atSYNCED: False, even when one of the same name exists in another namespace. For use across accounts there isClusterProviderConfig. SYNCEDmeans the request reached the vendor,READYmeans the resource is usable. Automation that waits forSYNCEDstarts too early.- Getting started with a secret holding credentials is convenient and permanent. When you move to Workload Identity, the step that is easy to forget is the cleanup: the old keys have to be revoked at the vendor, otherwise they keep working.
deletionPolicyisDeleteunless something else is stated. For anything that holds data,Orphanbelongs in the template.- Crossplane only pulls back what it manages itself, at the pace of the poll interval, and only for fields that appear in the manifest. Resources created by hand and fields left unset stay untouched.
When Crossplane pays off, when it does not
It pays off when the same kind of infrastructure is ordered again and again and the ordering people are not the experts for it. The tool shop with six teams that all need the same storage is this case. The effort for XRD and Composition pays for itself from the third or fourth order on, before that it is pure additional work.
Then there is the case where Kubernetes is already the place where things are operated. The cluster is then no additional construction site, and RBAC, GitOps and monitoring apply to the infrastructure as well.
For one-off setups the model does not carry. A network that comes into being once and then stands for ten years gains little from a permanent reconciliation and loses the readable plan before the execution.
It carries just as little when nobody wants to operate the cluster. Crossplane requires a cluster that is production infrastructure itself, with updates, backups and on-call. Whoever would have to build this cluster only for Crossplane is buying operations in order to save operations.
And against the will of the teams it never pays off. A platform API nobody orders from is more expensive than the ticket it was supposed to replace.
FAQ
Does Crossplane replace a classic infrastructure tool?
As a rule, no. The widespread pattern is a split: the run lays the foundation such as network and cluster, Crossplane delivers on top of it the resources teams order for themselves. Running both on the same resource leads to conflicts.
What happens if somebody changes a resource by hand?
If Crossplane manages this resource and the changed field is set in the manifest, the change is taken back at the next reconciliation. The Crossplane core checks every minute by default, packages generated from Terraform providers check every ten minutes per resource by default.
Does every team need its own cluster?
No, that is the purpose of the namespaces in version 2. Composite and Managed Resources sit in the namespace, the credentials through the ProviderConfig as well, and the separation is handled by RBAC.
What is the difference between XRD and Composition?
The XRD defines the type and its schema, so what can be ordered. The Composition defines what comes into being on an order. There can be several Compositions for one XRD, for example one per cloud.
Is there still a Crossplane with Claims?
The new modes Namespaced and Cluster know no Claims. Whoever needs them sets scope: LegacyCluster, the backwards compatibility mode. For a new setup there is no reason for that.
How many CustomResourceDefinitions does a provider install?
With the monolithic package of a large vendor it is over 900. Provider Families solve that by having you install only the package per service; for the object storage it is then a good two dozen types.
What about recurring tasks that do not create a resource?
For those there has been the type Operation since version 2, which runs a function pipeline through to the end once, on a schedule or on an event, similar to a Job. It is marked as alpha, so not yet intended for production.
Takeaway
Crossplane shifts the question. It is no longer how storage gets created, but who is allowed to order some and what applies automatically when they do. The tool shop from the beginning did not merely save a ticket, it ruled out two classes of mistake: copying with an omission, because there is nothing left to copy, and the unnoticed manual move, because the controller takes it back.
This is paid for with a cluster that becomes production infrastructure, and with a platform API that has to belong to somebody. Both are manageable, but both are real work and should be decided before the first XRD.
The next sensible step is small: take a local cluster, install the package for a single service and create a single managed resource. Then change it by hand at the vendor and wait out the reconciliation. That one attempt explains the model better than any description.
Sources
- Crossplane documentation, sections “What’s New in v2”, “Composite Resource Definitions”, “Compositions”, “Managed Resources”, “Providers” and “Get Started With Composition”, as of version 2.3
- Announcement of Crossplane 2.0 in the Crossplane blog
- Crossplane blog on the growth of CustomResourceDefinitions and on Provider Families
- Announcement by the CNCF on the graduation of Crossplane
- Reference of the AWS S3 provider in the Upbound Marketplace for the field names of the resources
All manifests and the tool shop example are our own and are written against Crossplane 2.3. The command line outputs are shortened and adjusted in the names.