CoursesCrossplaneManaged resources

Managed resources

One CRD per cloud resource.

Intermediate12 min · lesson 3 of 12

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.

terminal
$ kubectl api-resources --api-group=s3.aws.upbound.io
output
NAME SHORTNAMES APIVERSION NAMESPACED KIND
bucketacls s3.aws.upbound.io/v1beta1 false BucketACL
bucketpolicies s3.aws.upbound.io/v1beta1 false BucketPolicy
buckets s3.aws.upbound.io/v1beta1 false Bucket
bucketversionings 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).

bucket.yaml
apiVersion: s3.aws.upbound.io/v1beta1
kind: Bucket # one CRD == one cloud resource type
metadata:
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 schema
providerConfigRef:
name: default # which cloud credentials the controller uses
terminal
$ kubectl apply -f bucket.yaml
output
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.

bucket-managed.yaml
apiVersion: s3.aws.upbound.io/v1beta1
kind: Bucket
metadata:
name: reports-bucket
annotations:
crossplane.io/external-name: acme-reports-prod # real bucket name in AWS
spec:
managementPolicies: ["Observe"] # read-only: look, never touch
deletionPolicy: Orphan # keep the bucket if this object is deleted
forProvider:
region: us-east-1
tags:
team: platform # tags is a real forProvider field
providerConfigRef:
name: default
The parts of one managed resource
metadata
name
the Kubernetes object name
external-name
the real ID in the cloud
spec.forProvider
region, tags, forceDestroy
the desired state you set
maps to provider schema
one field per cloud setting
spec controls
providerConfigRef
which credentials
managementPolicies
which verbs allowed
deletionPolicy
Delete or Orphan
status
atProvider
observed state from the cloud
Synced / Ready
conditions you watch
You write the top three sections. The controller writes status back.

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.

terminal
$ kubectl get buckets
output
NAME READY SYNCED EXTERNAL-NAME AGE
reports-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.

terminal
$ kubectl get managed
output
NAME READY SYNCED EXTERNAL-NAME AGE
bucket.s3.aws.upbound.io/reports-bucket True True acme-reports-prod 6m
NAME READY SYNCED EXTERNAL-NAME AGE
role.iam.aws.upbound.io/app-exec-role True True app-exec-role 6m
NAME READY SYNCED EXTERNAL-NAME AGE
instance.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.

terminal
$ kubectl describe bucket reports-bucket
output
Name: reports-bucket
API Version: s3.aws.upbound.io/v1beta1
Kind: Bucket
Metadata:
Annotations:
crossplane.io/external-name: acme-reports-prod
Status:
At Provider:
Arn: arn:aws:s3:::acme-reports-prod
Conditions:
Last Transition Time: 2026-07-21T11:20:04Z
Message: 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
Reason: ReconcileError
Status: False
Type: Synced
Last Transition Time: 2026-07-21T10:55:12Z
Reason: Available
Status: True
Type: Ready
Events:
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.

terminal
$ kubectl delete bucket reports-bucket
output
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.

Deleting the Kubernetes object deletes the cloud resource
With the default deletionPolicy: Delete, kubectl delete bucket reports-bucket runs DeleteBucket in your cloud account. There is no undo. Before you let any person or GitOps controller delete managed resources, set deletionPolicy: Orphan on every stateful resource and gate these CRDs behind tight RBAC. In review, treat a deleted managed resource exactly like running the cloud destroy command by hand, because that is what it is.

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.

terminal
$ kubectl get bucket reports-bucket \
-o jsonpath='{.metadata.deletionTimestamp}{" "}{.metadata.finalizers}'
output
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.

Force-removing the finalizer abandons real infrastructure
It is tempting to unstick a Terminating object by editing out finalizer.managedresource.crossplane.io. Do not, unless you already know for certain the cloud resource is gone. Removing the finalizer deletes only the Kubernetes record. The real bucket or database keeps running, keeps costing money, and now nothing in your cluster tracks it. You have created shadow infrastructure: still live, still a target, no longer in any inventory. Fix the credentials instead.
Quick check
01A teammate has Kubernetes RBAC to create Bucket and Role objects in the cluster, but holds no AWS credentials of their own. In the classic cluster-scoped model, what can they do to your AWS account?
Correct — The provider holds the cloud credentials; anyone who can apply a managed resource borrows that power, so RBAC on these CRDs is your cloud blast radius.
Incorrect — The controller authenticates with the provider's credentials, not the user's. The user never touches AWS directly.
Incorrect — Creating a managed resource makes the controller run a real create call; no user-held cloud credentials are involved.
Incorrect — Classic managed resources are cluster-scoped, so namespaces do not contain them.
02In the Upbound AWS provider family you create a Bucket, but there is no versioning or public-access-block field under its spec.forProvider. Where do those settings actually live?
Incorrect — they are not annotations; each is a full managed resource in its own right.
Correct — the family splits one console page into several managed resources, which lets a defender put separate RBAC on a sensitive setting like the public-access block.
Incorrect — these are per-bucket resources, not global ProviderConfig settings.
Incorrect — they are fully manageable as their own Crossplane resources, with no console step required.
03You run kubectl delete bucket reports-bucket. The command hangs and the object sits in Terminating. You then discover the provider's cloud credentials were revoked an hour ago. What is happening, and what is the right fix?
Incorrect — this is a finalizer waiting on the controller, not an API server fault.
Incorrect — removing the finalizer deletes only the Kubernetes record and abandons the live cloud resource as untracked shadow infrastructure.
Correct — a managed resource holds its finalizer until the external resource is confirmed deleted, so fixing the credentials lets the delete complete on its own.
Incorrect — Orphan keeps the cloud resource, and flipping it mid-delete is not what unblocks a finalizer waiting on cloud access.

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.

Related