Installing Crossplane & a provider
The core + a cloud provider package.
A brand-new smartphone can do almost nothing on its own. It ships with an operating system and an app store, so it knows how to find, install, and run apps, but until you install one, it cannot hail a cab or send a photo. Installing Crossplane has the same shape, and it takes two steps. First you install the core: a small set of controllers (little programs that watch the cluster and act on what they see) that turn an ordinary Kubernetes cluster into a control plane and teach it to handle packages. A control plane behaves like a thermostat. You set the target once, and it keeps nudging reality toward that target on its own. Here the target is the infrastructure you declared, and the control plane keeps driving real cloud resources toward it. The core is the operating system and the store client rolled into one. It can install and run cloud 'apps', but it ships with none of them, so it cannot create a single bucket or database yet.
The second step is installing a provider: one package that pulls a controller plus a stack of new API types. An API (Application Programming Interface) is the set of object kinds and requests your cluster understands, so adding types means the cluster learns to speak a whole new cloud service, like S3 (Amazon's Simple Storage Service), RDS (Relational Database Service), or VPC (Virtual Private Cloud). Core first. Providers on top.
Install the core with Helm
Helm is the package manager for Kubernetes, the apt or Homebrew of the cluster. The core ships as a Helm chart from Crossplane's stable chart repository, and it wants its own namespace, crossplane-system. A namespace is just a named partition inside the cluster that keeps related objects together, and this one holds the core's controllers, the package manager, and its permissions. Nothing here touches a cloud. You are installing the engine that will manage packages later, not anything that can call AWS (Amazon Web Services). So add the repository, look at which versions exist, and pin the one you install. Pinning matters for the same reason you would not let a production fleet auto-update every app overnight. You want the version you chose and tested, not whatever happens to be newest the second your pipeline runs.
# add Crossplane's stable Helm chart repo and refresh the local indexhelm repo add crossplane-stable https://charts.crossplane.io/stablehelm repo update# see which chart versions exist instead of blindly taking the newesthelm search repo crossplane-stable/crossplane --versions | head -4
"crossplane-stable" has been added to your repositoriesHang tight while we grab the latest from your chart repositories......Successfully got an update from the "crossplane-stable" chart repositoryUpdate Complete. ⎈Happy Helming!⎈NAME CHART VERSION APP VERSION DESCRIPTIONcrossplane-stable/crossplane 2.3.0 2.3.0 Crossplane is an open source Kubernetes add-on ...crossplane-stable/crossplane 2.2.0 2.2.0 Crossplane is an open source Kubernetes add-on ...crossplane-stable/crossplane 2.1.0 2.1.0 Crossplane is an open source Kubernetes add-on ...
Now install the chart into its namespace at a pinned version. The --wait flag tells Helm to block until the deployments are truly running, not merely accepted by the API server, so a successful return actually means the pods came up. If anything fails to become ready, --wait returns a non-zero exit code, which is exactly what you want in a pipeline. Add --atomic if you also want a failed install to roll itself back instead of leaving half a release behind.
# install the core control-plane components, pinned, into their own namespacehelm install crossplane crossplane-stable/crossplane \--namespace crossplane-system --create-namespace \--version 2.3.0 \--wait
NAME: crossplaneLAST DEPLOYED: Tue Jul 21 09:14:02 2026NAMESPACE: crossplane-systemSTATUS: deployedREVISION: 1TEST SUITE: NoneNOTES:Release: crossplaneChart Name: crossplaneChart Description: Crossplane is an open source Kubernetes add-on that enables platform teams to assemble infrastructure from multiple vendors.Chart Version: 2.3.0Chart Application Version: 2.3.0Kube Version: v1.30.2
# --wait already blocked until they were ready; confirm the two core podskubectl get pods -n crossplane-system
NAME READY STATUS RESTARTS AGEcrossplane-6d67f8cd9d-g2gjw 1/1 Running 0 63scrossplane-rbac-manager-86d9b5cf9f-2vc4s 1/1 Running 0 63s
Two pods, and that is the entire core. (A pod is the smallest thing Kubernetes runs, one or more containers scheduled together.) The first pod, crossplane, holds the package manager and the reconcilers that install and run everything you add later. A reconciler is the loop that keeps comparing what you asked for against what actually exists and closing the gap. When the core started it also registered a handful of its own API types, the packaging machinery: Provider, Configuration, Function, and the revision and Lock objects that track them.
The second pod, crossplane-rbac-manager, has one focused job. Think of RBAC as the building's keycard system, deciding who may open which doors. RBAC stands for Role-Based Access Control, Kubernetes' permission system. As new API types arrive from the providers you install, the rbac-manager writes the access rules so the core and each provider get exactly the permissions their new types need, and no more. Hold that picture as a defender. The instant this core is healthy, your cluster is a control plane that can grow the power to create and destroy cloud infrastructure. Anyone who can reach it, or install packages into it, is one short step from your cloud account.
Add a provider package
A provider is installed the Kubernetes way: declaratively. You describe the end state you want in a small file and let the cluster make it real, instead of running a sequence of commands yourself. You write a Provider object, apply it, and the core does the rest. It pulls the package from an OCI registry (OCI, the Open Container Initiative, is the standard format for container images) at xpkg.upbound.io, starts the provider's controller as a Deployment (a Kubernetes object that keeps a set of identical pods running and restarts them if they die), and registers one new API type per cloud resource the provider manages. Picture xpkg.upbound.io as the app store and the Provider object as tapping install on one specific version. Pin that version. A control plane should never roll to :latest on its own, because :latest hands the choice of which code runs, with your cluster's permissions, to the registry and to anyone who can push to it. For anything you care about, pin the immutable digest (the sha256 fingerprint of the exact image bytes), not only the tag.
# provider-aws-s3.yaml: one member of the AWS provider familyapiVersion: pkg.crossplane.io/v1kind: Providermetadata:name: provider-aws-s3spec:# a tag is readable; a digest is immutable. Prefer the digest in production.package: xpkg.upbound.io/upbound/provider-aws-s3:v2.6.1# e.g. xpkg.upbound.io/upbound/provider-aws-s3@sha256:9f2b1c... pins exact bytes
kubectl apply -f provider-aws-s3.yaml# right after applying, HEALTHY reads Unknown while the image pullskubectl get providers
provider.pkg.crossplane.io/provider-aws-s3 createdNAME INSTALLED HEALTHY PACKAGE AGEprovider-aws-s3 True Unknown xpkg.upbound.io/upbound/provider-aws-s3:v2.6.1 9s
INSTALLED flips to True the moment the package is downloaded. HEALTHY stays Unknown until the controller image is pulled and its API types are established, which on a fresh cluster takes a minute or two. Wait that minute and look again. Now a provider you never applied appears: upbound-provider-family-aws. That is not a mistake. provider-aws-s3 declares a dependency on the shared AWS family package, and Crossplane resolved it for you, the way installing one app can pull in a shared framework it needs. Crossplane records that resolution in a cluster-scoped Lock object (run kubectl get lock to see it), so the whole dependency graph stays inspectable. The family provider is the one that owns the ProviderConfig type every AWS resource uses to sign in, which is the whole of the next lesson, xp-providerconfig. For now, both rows reading True is your green light.
# give it a minute, then look againkubectl get providers
NAME INSTALLED HEALTHY PACKAGE AGEprovider-aws-s3 True True xpkg.upbound.io/upbound/provider-aws-s3:v2.6.1 2m11supbound-provider-family-aws True True xpkg.upbound.io/upbound/provider-family-aws:v2.6.1 96s
Each provider adds new API types to your cluster, and those types are CRDs. A CRD (Custom Resource Definition) is the hook Kubernetes gives you to add your own kinds of object to its API, the same way installing an app can teach your phone to open a file type it never knew before. A moment ago kubectl knew about Pods and Services. Now it knows about S3 buckets and their many settings. The S3 provider alone registers around two dozen of them, one per S3 resource type, from the Bucket itself down to its encryption, versioning, and public-access-block configs.
# every new type the S3 provider taught your cluster to speak (first 10 shown)kubectl get crds | grep s3.aws.upbound.io | head
bucketaccelerateconfigurations.s3.aws.upbound.io 2026-07-21T09:16:44Zbucketacls.s3.aws.upbound.io 2026-07-21T09:16:44Zbucketanalyticsconfigurations.s3.aws.upbound.io 2026-07-21T09:16:44Zbucketcorsconfigurations.s3.aws.upbound.io 2026-07-21T09:16:44Zbucketintelligenttieringconfigurations.s3.aws.upbound.io 2026-07-21T09:16:44Zbucketinventories.s3.aws.upbound.io 2026-07-21T09:16:44Zbucketlifecycleconfigurations.s3.aws.upbound.io 2026-07-21T09:16:44Zbucketloggings.s3.aws.upbound.io 2026-07-21T09:16:44Zbucketmetrics.s3.aws.upbound.io 2026-07-21T09:16:44Zbucketnotifications.s3.aws.upbound.io 2026-07-21T09:16:44Z
Here is the rule that saves you real pain: install the family member you need, never the old monolith. The AWS, Azure, and GCP providers were split into families for a reason. The original monolithic provider-aws registers close to a thousand CRDs in one shot, which bloats the API server, slows discovery for every client that lists types, and on smaller clusters can push etcd (the key-value database where all Kubernetes state lives) toward its storage limits, sometimes leaving the provider stuck at HEALTHY: False while CRDs churn. Install only the family member you actually use, provider-aws-s3, provider-aws-rds, and so on. Each one pulls the shared family controller automatically, so you get exactly the types you need and nothing else. If you already applied the monolith, delete that Provider and let Crossplane garbage-collect its CRDs before you switch, because running the monolith and a family member for the same service will fight over ownership of the same CRDs.
Verify it, and watch it like an attacker would
A healthy provider means a new Deployment is now running inside crossplane-system, built from an image you pulled off the internet, holding a service account (the identity a pod runs as) wired to manage a class of cloud resources. Look at that directly. It confirms your install, and it is exactly what an intruder inspects after landing on the cluster. If a provider ever sits at HEALTHY: False, the same instinct applies: run kubectl get providerrevisions and kubectl describe provider provider-aws-s3, where the events spell out why the package would not install.
kubectl get deploy -n crossplane-system
NAME READY UP-TO-DATE AVAILABLE AGEcrossplane 1/1 1 1 6mcrossplane-rbac-manager 1/1 1 1 6mprovider-aws-s3-9c4e2f1a8b3d 1/1 1 1 2mupbound-provider-family-aws-1f7a2c9d0e5b 1/1 1 1 96s
Two of those Deployments did not exist ten minutes ago, and each runs vendor code under an identity that can change your infrastructure. On any cluster running Crossplane, the control plane is the prize, because reaching it is a shortcut to the cloud account behind it. The permission to create Provider objects (create on providers.pkg.crossplane.io) sits close to full administrator over your infrastructure. Whoever holds it can install a package of their choosing and get a controller running with the core's blessing.
Two habits pay off. Watch the audit log (the cluster's running record of every API request) and your Git history for new or changed Provider objects, because in a healthy GitOps setup (where every infrastructure change flows through a reviewed commit) each provider install is a commit you can point to, and a surprise Provider is a signal worth an alert. And keep the right to create packages with the small group that runs the platform.
One more thing to know before you run anything destructive: removing a Provider deletes its CRDs, and deleting a resource's CRD tears down the objects under it. Because most managed resources default to a Delete deletion policy, uninstalling a provider on a cluster that owns live infrastructure can cascade into deleting real buckets and databases. Treat kubectl delete provider on a production control plane with the same care as a delete in the cloud console. There is a security edge here too: an attacker who can delete packages can wipe your infrastructure without ever signing in to the cloud provider directly.
Try this
Run helm repo add crossplane-stable https://charts.crossplane.io/stable 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: a Provider runs vendor code in your cluster. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.