Managed resources
One CRD per cloud resource.
A managed resource is a light switch on the wall for exactly one thing in your cloud account. You flip the switch, and a wire you cannot see carries the signal to the real bulb in a data center far away. In Crossplane, the switch is a small YAML (a plain-text format for configuration) object you apply to Kubernetes. The wire is a controller (a program that runs a loop) inside your cluster. The bulb is one real S3 (Simple Storage Service) bucket, one RDS (Relational Database Service) database, or one IAM (Identity and Access Management) role. You never open a cloud console. You never run a cloud command-line tool. You write down what you want, and the controller on the other end keeps pressing buttons in AWS, Google Cloud, or Azure until the real thing matches your description.
Because that object is an ordinary Kubernetes citizen, you can get it, describe it, label it, and lock it down with kubectl (the Kubernetes command-line tool), the same way you manage a Pod (the smallest unit Kubernetes runs). One kind of cloud thing maps to one Kubernetes object type. The bucket is a Bucket. The database is an Instance. The role is a Role. These are real API (Application Programming Interface) objects, with a spec you write and a status the controller writes back.
One CRD per cloud resource
Installing the provider in the last lesson did something dramatic to your cluster. A provider package registers dozens or hundreds of CRDs (Custom Resource Definitions). A CRD is how you teach the Kubernetes API a brand-new kind of object, like handing the front desk a new type of form it now knows how to accept and file. One CRD per external resource the cloud exposes. That is the whole trick of Crossplane in one line: the cloud's API surface becomes your Kubernetes API surface. After installing an AWS provider, you can list what showed up.
$ kubectl api-resources --api-group=s3.aws.upbound.io
NAME SHORTNAMES APIVERSION NAMESPACED KINDbucketacls s3.aws.upbound.io/v1beta1 false BucketACLbucketpolicies s3.aws.upbound.io/v1beta1 false BucketPolicybuckets s3.aws.upbound.io/v1beta1 false Bucketbucketversionings s3.aws.upbound.io/v1beta1 false BucketVersioning# ...and more: encryption, public-access block, lifecycle, notifications
Look at the NAMESPACED column. It says false. Classic managed resources are cluster-scoped, which means they do not live inside a namespace (Kubernetes' folder-like walls that keep one team's objects separate from another's). That has a sharp security consequence. Namespaces cannot fence these off, so the only real boundary left is who holds RBAC (Role-Based Access Control, the Kubernetes permission system) rights on these CRDs. Anyone who can create a Bucket object can create a real bucket in your account, using the provider's credentials, without holding any cloud credentials of their own.
Sit with that, because it is the security spine of this lesson. The provider holds one powerful set of cloud credentials. Every engineer who can apply a managed resource borrows that power through the cluster. Someone with rights to create Role objects in iam.aws.upbound.io can mint an IAM role with administrator access. Someone who can create an EC2 (Elastic Compute Cloud) Instance can spin up a fleet of servers to mine cryptocurrency on your bill. Neither of them ever logs in to AWS. So the RBAC on these CRDs is your cloud blast radius (how far the damage spreads if one account is misused), and the command kubectl get managed, shown further down, is how you audit what that radius is holding right now.
Here is the smallest useful managed resource: an S3 bucket handled by the Upbound AWS provider family (the modern providers where S3, RDS, and IAM each ship as their own package instead of one giant binary).
apiVersion: s3.aws.upbound.io/v1beta1kind: Bucket # one CRD == one cloud resource typemetadata:name: reports-bucket # the Kubernetes object name (not the AWS name)spec:forProvider:region: us-east-1 # every field here maps to the provider's schemaproviderConfigRef:name: default # which cloud credentials the controller uses
$ kubectl apply -f bucket.yaml
bucket.s3.aws.upbound.io/reports-bucket created
Anatomy of a managed resource
Every managed resource, on every cloud, shares one skeleton. Learn it once and you can read a Google Cloud database or an Azure key vault at a glance.
spec.forProvider is the desired external state, the part where you describe what the cloud thing should look like. Its fields are close to a one-to-one copy of the provider's schema for that resource, so region, tags, and forceDestroy are real fields on the S3 Bucket. One honest surprise waits here. Settings that the AWS console shows on a single page are often split into separate managed resources in the Upbound family. Bucket versioning is not a field on the bucket. It is its own BucketVersioning object that points back at the bucket, and the same goes for the bucket policy, the public-access block, and server-side encryption. That split works in your favor as a defender: you can put separate RBAC on the public-access block and review that one sensitive setting on its own.
spec.providerConfigRef names the credentials the controller authenticates with, wired up in the ProviderConfig lesson. spec.managementPolicies is the list of actions the controller is allowed to take against the cloud, held like a labelled keyring where each key is one verb. The default, ["*"], is the master key: the controller can observe, create, update, and delete. Narrow it to ["Observe"] and the controller may only look, never touch. That one change turns a managed resource into a read-only mirror of real infrastructure, exactly what you want when you are importing or auditing production and a stray typo must not be able to rewrite it. Management policies are on by default in current Crossplane.
spec.deletionPolicy decides the fate of the real resource when you delete the Kubernetes object. Delete, the default, destroys it. Orphan leaves the cloud resource standing and removes only the Kubernetes record.
The crossplane.io/external-name annotation (a small labelled note pinned to the object) holds the resource's real name or ID in the cloud. For an S3 bucket that is the globally unique bucket name. Set it before you apply, and Crossplane adopts the existing bucket instead of creating a new one, which is how you bring already-running infrastructure under management. Leave it off and the controller creates the resource, then writes the generated name back into this annotation for you. Everything the controller learns on the far end flows into status.atProvider, next to two conditions you will read constantly: Synced and Ready.
apiVersion: s3.aws.upbound.io/v1beta1kind: Bucketmetadata:name: reports-bucketannotations:crossplane.io/external-name: acme-reports-prod # real bucket name in AWSspec:managementPolicies: ["Observe"] # read-only: look, never touchdeletionPolicy: Orphan # keep the bucket if this object is deletedforProvider:region: us-east-1tags:team: platform # tags is a real forProvider fieldproviderConfigRef:name: default
Reading their state with kubectl
Because managed resources are ordinary Kubernetes objects, kubectl drives every one of them. The first thing to learn to read is two columns that look alike and mean very different things.
$ kubectl get buckets
NAME READY SYNCED EXTERNAL-NAME AGEreports-bucket True True acme-reports-prod 3m
SYNCED=True means the controller reached the cloud API on its last pass and reconciled your spec with reality, that is, it compared what you asked for against what exists and corrected any drift. READY=True means the cloud itself reports the resource as usable. The two move independently. A fresh RDS database can sit at SYNCED=True and READY=False for ten minutes while AWS provisions hardware in the background. When SYNCED is False, the controller could not finish its job, and the cause is often a credentials or permissions problem you need to see.
$ kubectl get managed
NAME READY SYNCED EXTERNAL-NAME AGEbucket.s3.aws.upbound.io/reports-bucket True True acme-reports-prod 6mNAME READY SYNCED EXTERNAL-NAME AGErole.iam.aws.upbound.io/app-exec-role True True app-exec-role 6mNAME READY SYNCED EXTERNAL-NAME AGEinstance.rds.aws.upbound.io/orders-db False True orders-db-01 2m
kubectl get managed is the one command that lists every managed resource of every kind in the cluster. For an operator, that is your live inventory of cloud infrastructure. For a defender, it is the fastest audit you have: one screen showing everything the provider's credentials are currently holding open. If a Bucket or Role shows up that nobody remembers creating, that is your cue to find out who applied it and why.
When SYNCED flips to False, kubectl describe tells you why. It prints the provider's error text word for word, straight from the cloud API, on the Synced condition and again in the Events block at the bottom.
$ kubectl describe bucket reports-bucket
Name: reports-bucketAPI Version: s3.aws.upbound.io/v1beta1Kind: BucketMetadata:Annotations:crossplane.io/external-name: acme-reports-prodStatus:At Provider:Arn: arn:aws:s3:::acme-reports-prodConditions:Last Transition Time: 2026-07-21T11:20:04ZMessage: observe failed: cannot run refresh: refresh failed: reading S3 Bucket (acme-reports-prod): AccessDenied: User: arn:aws:iam::123456789012:user/crossplane is not authorized to perform: s3:GetBucketTaggingReason: ReconcileErrorStatus: FalseType: SyncedLast Transition Time: 2026-07-21T10:55:12ZReason: AvailableStatus: TrueType: ReadyEvents:Type Reason Age From Message---- ------ ---- ---- -------Warning CannotObserveExternalResource 9s (x5 over 66s) managed/bucket.s3.aws.upbound.io observe failed: cannot run refresh: refresh failed: reading S3 Bucket (acme-reports-prod): AccessDenied: User: arn:aws:iam::123456789012:user/crossplane is not authorized to perform: s3:GetBucketTagging
Read the Events block first. It shows the error and how long it has been repeating, here five times over 66 seconds, which tells you this is a stuck loop rather than a one-off blip. The provider's IAM user is missing the s3:GetBucketTagging permission, so the controller cannot even read the bucket. Synced flips to False, and the message names the exact permission to add. Notice that Ready is still True from earlier: the bucket exists and works fine, but Crossplane has gone blind to it. Permission denied, name already taken, invalid region, quota exceeded: the fix is almost always sitting in this block, which is why describe is the first command to run when a colleague says a resource is stuck.
One habit to build now: never rename a managed resource by editing metadata.name. Kubernetes has no rename operation. Change the name and you have told it to delete the old object and create a new one, which, depending on your deletion policy, can destroy and recreate real infrastructure underneath you. To point at a different cloud resource, change the external-name annotation instead.
Deleting is a live action
Here is the part that surprises people the first time, and it usually happens in production.
$ kubectl delete bucket reports-bucket
bucket.s3.aws.upbound.io "reports-bucket" deleted
With the default deletionPolicy: Delete, that command reached past Kubernetes and into AWS. It called DeleteBucket in your account and destroyed the real bucket and everything inside it. There is no undo and no trash can. For anything that holds data (databases, buckets with objects, disks), set deletionPolicy: Orphan or lock the resource to managementPolicies: ["Observe"], so a stray kubectl delete, a bad GitOps sync (an automated controller re-applying whatever is committed in a Git repository), or a deleted namespace cannot cascade into a data-loss incident.
Managed resources also carry a finalizer, a small marker that tells Kubernetes: do not actually remove this object until I confirm the cleanup finished. Think of it as a hold placed on a shipment until the warehouse signs off. When you delete a managed resource, the controller has to reach the cloud, delete the external resource, confirm it is gone, and only then release the finalizer. If it cannot reach the cloud, because the credentials expired, the IAM role was revoked, or the network path is blocked, it can confirm nothing. So the object hangs in Terminating and the delete command blocks.
$ kubectl get bucket reports-bucket \-o jsonpath='{.metadata.deletionTimestamp}{" "}{.metadata.finalizers}'
2026-07-21T11:04:12Z ["finalizer.managedresource.crossplane.io"]
The right fix is to restore whatever the controller needs, renew the credentials, re-grant the IAM permission, or open the network path, and let the controller finish the delete on its own. It will, the moment it can reach the cloud again.
So here is the one thing to carry out of this lesson. The RBAC rules on these managed-resource CRDs are worth exactly as much as the cloud credentials sitting behind the provider, because they grant the same power. Audit who can create a Role or a Bucket object in your cluster with the same seriousness you would audit who holds an AWS admin key, and keep both lists short. In Crossplane, they are the same power wearing a Kubernetes coat.
Try this
Run kubectl api-resources --api-group=s3.aws.upbound.io 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: deleting the Kubernetes object deletes the cloud resource. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.