Every platform team eventually hits the same wall: application teams need an S3 bucket and a Postgres instance, and they need them today. The options are usually "open a ticket" (slow), "give them Terraform access" (scary), or "build a self-service API" (expensive). Crossplane positions itself as the third option, implemented with CRDs you already know how to operate.
This article walks through a concrete case — an S3 bucket plus an RDS instance, exposed to developers as a single namespaced Claim — and compares it honestly with the terraform plan/apply workflow. By the end you should be able to write an XRD, a Composition, hand a Claim to your teams, and know when Crossplane is not the right tool.
Context: the lifecycle ownership problem
Terraform's model is a transaction: you compute a diff against state, a human reviews it, and the apply executes. It is excellent for "build this VPC once, review every change carefully". It is mediocre at "fifty teams create and destroy buckets every week", because each of those operations needs a pipeline run, a state lock, a reviewer, and a merge.
Crossplane inverts the model. You install controllers in your cluster that own AWS resources as Kubernetes objects. There is no plan step and no state file: the desired state is the Kubernetes object, the actual state is the AWS API, and a reconciliation loop closes the gap — forever, every few minutes.
That difference drives everything else:
| Concern | Terraform | Crossplane |
|---|---|---|
| Desired state | HCL + state file in S3/TFC | etcd (the CR itself) |
| Execution | On demand, in CI | Continuous reconciliation (default poll 10m) |
| Preview | terraform plan | None natively (dry-run functions, crossplane render) |
| Drift | Detected only when you run plan | Corrected automatically |
| Self-service | PR to a repo | kubectl apply of a Claim, RBAC-scoped |
| Blast radius | A whole state file | One managed resource at a time |
Neither wins outright. Crossplane's auto-correction is a feature when someone fat-fingers a bucket policy in the console, and a liability when an SRE legitimately needs a temporary manual change. Terraform's plan gate is a feature for a production VPC and pure friction for the 400th dev bucket.
Installing Crossplane and the AWS provider
Crossplane runs fine on OpenShift; the Upbound provider images run as UID 65532 and are compatible with the restricted-v2 SCC, so no SCC exception is needed for the controllers themselves.
helm repo add crossplane-stable https://charts.crossplane.io/stable helm repo update helm install crossplane crossplane-stable/crossplane \ --namespace crossplane-system --create-namespace \ --version 1.18.2 --wait kubectl get pods -n crossplane-system The monolithic provider-aws is gone. Upbound ships one provider per service family, which keeps the CRD count sane — installing the full AWS provider used to create ~900 CRDs and melt small API servers.
apiVersion: pkg.crossplane.io/v1beta1 kind: DeploymentRuntimeConfig metadata: name: aws-irsa spec: serviceAccountTemplate: metadata: annotations: eks.amazonaws.com/role-arn: arn:aws:iam::111122223333:role/crossplane-provider-aws --- apiVersion: pkg.crossplane.io/v1 kind: Provider metadata: name: provider-aws-s3 spec: package: xpkg.upbound.io/upbound/provider-aws-s3:v1.21.0 runtimeConfigRef: name: aws-irsa --- apiVersion: pkg.crossplane.io/v1 kind: Provider metadata: name: provider-aws-rds spec: package: xpkg.upbound.io/upbound/provider-aws-rds:v1.21.0 runtimeConfigRef: name: aws-irsa --- apiVersion: pkg.crossplane.io/v1 kind: Function metadata: name: function-patch-and-transform spec: package: xpkg.upbound.io/crossplane-contrib/function-patch-and-transform:v0.8.2 Credentials come from the pod identity, never from a static key:
apiVersion: aws.upbound.io/v1beta1 kind: ProviderConfig metadata: name: default spec: credentials: source: IRSA kubectl get providers kubectl get providerconfig.aws.upbound.io If you are not on EKS/ROSA with STS, use source: WebIdentity with an OIDC-federated role, or at worst a Secret — but see the security section.
Defining the API your teams will consume: the XRD
The CompositeResourceDefinition (XRD) is the contract. It generates two CRDs: a cluster-scoped Composite Resource (XR) and a namespaced Claim. Developers only ever touch the Claim.
Design rule: the XRD schema should expose product decisions, not AWS fields. If a developer can set blockPublicAcls: false, you have built a thin wrapper around the AWS API and gained nothing.
apiVersion: apiextensions.crossplane.io/v1 kind: CompositeResourceDefinition metadata: name: xobjectstores.platform.acme.io spec: group: platform.acme.io names: kind: XObjectStore plural: xobjectstores claimNames: kind: ObjectStore plural: objectstores defaultCompositionRef: name: objectstore-aws versions: - name: v1alpha1 served: true referenceable: true schema: openAPIV3Schema: type: object properties: spec: type: object properties: parameters: type: object properties: region: type: string enum: ["eu-west-1", "eu-west-3"] versioning: type: boolean default: true dataClassification: type: string enum: ["internal", "confidential"] default: internal required: ["region"] required: ["parameters"] status: type: object properties: bucketName: type: string Three fields. No ACLs, no encryption toggle, no public access knobs — those are decided by the platform, in the Composition.
The Composition: where the opinions live
A Composition maps one XR to N managed resources. Since Crossplane 1.17, Compositions run as a function pipeline; function-patch-and-transform reproduces the classic patching behaviour.
apiVersion: apiextensions.crossplane.io/v1 kind: Composition metadata: name: objectstore-aws spec: compositeTypeRef: apiVersion: platform.acme.io/v1alpha1 kind: XObjectStore mode: Pipeline pipeline: - step: patch-and-transform functionRef: name: function-patch-and-transform input: apiVersion: pt.fn.crossplane.io/v1beta1 kind: Resources resources: - name: bucket base: apiVersion: s3.aws.upbound.io/v1beta1 kind: Bucket spec: forProvider: region: eu-west-3 tags: managed-by: crossplane patches: - type: FromCompositeFieldPath fromFieldPath: spec.parameters.region toFieldPath: spec.forProvider.region - type: FromCompositeFieldPath fromFieldPath: spec.parameters.dataClassification toFieldPath: spec.forProvider.tags[classification] - type: ToCompositeFieldPath fromFieldPath: metadata.annotations[crossplane.io/external-name] toFieldPath: status.bucketName - name: block-public-access base: apiVersion: s3.aws.upbound.io/v1beta1 kind: BucketPublicAccessBlock spec: forProvider: blockPublicAcls: true blockPublicPolicy: true ignorePublicAcls: true restrictPublicBuckets: true bucketSelector: matchControllerRef: true patches: - type: FromCompositeFieldPath fromFieldPath: spec.parameters.region toFieldPath: spec.forProvider.region - name: encryption base: apiVersion: s3.aws.upbound.io/v1beta1 kind: BucketServerSideEncryptionConfiguration spec: forProvider: rule: - applyServerSideEncryptionByDefault: - sseAlgorithm: aws:kms bucketKeyEnabled: true bucketSelector: matchControllerRef: true patches: - type: FromCompositeFieldPath fromFieldPath: spec.parameters.region toFieldPath: spec.forProvider.region - name: versioning base: apiVersion: s3.aws.upbound.io/v1beta1 kind: BucketVersioning spec: forProvider: versioningConfiguration: - status: Enabled bucketSelector: matchControllerRef: true patches: - type: FromCompositeFieldPath fromFieldPath: spec.parameters.region toFieldPath: spec.forProvider.region - type: FromCompositeFieldPath fromFieldPath: spec.parameters.versioning toFieldPath: spec.forProvider.versioningConfiguration[0].status transforms: - type: convert convert: toType: string - type: map map: "true": Enabled "false": Suspended matchControllerRef: true is the Crossplane equivalent of aws_s3_bucket.this.id in HCL: it resolves the reference to the sibling resource created by the same XR, without you knowing the generated bucket name.
Validate the rendering before applying anything to the cluster — this is the closest thing Crossplane has to terraform plan:
crossplane render examples/claim.yaml composition.yaml functions.yaml The Claim: what developers actually write
apiVersion: platform.acme.io/v1alpha1 kind: ObjectStore metadata: name: invoices namespace: team-billing spec: parameters: region: eu-west-3 dataClassification: confidential kubectl apply -f claim.yaml kubectl get objectstore -n team-billing # NAME SYNCED READY CONNECTION-SECRET AGE # invoices True True 62s Four lines of YAML, in their own namespace, gated by their own RBAC. No AWS credentials, no state backend, no pipeline.
Day-2 operations: observing the fleet
This is where Crossplane's Kubernetes-native nature pays off. Every AWS object is a queryable resource:
# Every managed resource in the cluster, all providers kubectl get managed # Only S3, with sync/ready conditions kubectl get bucket.s3.aws.upbound.io # Anything not reconciling kubectl get managed -o json \ | jq -r '.items[] | select(.status.conditions[]? | select(.type=="Synced" and .status!="True")) | "\(.kind)/\(.metadata.name)"' # Full ownership tree from the claim down crossplane beta trace objectstore.platform.acme.io/invoices -n team-billing Drift correction is the headline difference. Suspend versioning out of band and watch it come back:
aws s3api put-bucket-versioning --bucket invoices-xyz123 \ --versioning-configuration Status=Suspended # within the poll interval (default 10m, --poll-interval on the provider) kubectl describe bucketversioning.s3.aws.upbound.io | grep -A3 Events With Terraform you would learn about this at the next terraform plan -detailed-exitcode in a nightly job — assuming you built that job.
The escape hatch when you do need a manual change: managementPolicies: ["Observe"] on the managed resource freezes Crossplane into read-only mode for that object.
Adding RDS: where it gets real
Buckets are easy. RDS exposes the parts that matter: credentials and deletion safety.
- name: db base: apiVersion: rds.aws.upbound.io/v1beta1 kind: Instance spec: deletionPolicy: Orphan forProvider: engine: postgres engineVersion: "16.3" instanceClass: db.t4g.small allocatedStorage: 20 storageEncrypted: true publiclyAccessible: false skipFinalSnapshot: false autoGeneratePassword: true passwordSecretRef: namespace: crossplane-system name: invoices-db-master key: password writeConnectionSecretToRef: namespace: crossplane-system name: invoices-db-conn autoGeneratePassword: true makes the provider generate the password and store it in the referenced Secret — the password never transits through Git or a Composition. The connection secret (endpoint, port, username, password, attribute.*) is then propagated to the claim's namespace via the XRD's connectionSecretKeys and the claim's writeConnectionSecretToRef.
deletionPolicy: Orphan is non-negotiable for stateful resources. The default is Delete, which means kubectl delete objectstore invoices destroys a production database. Pair it with the Usage resource (--enable-usages) to block deletion while a consumer exists.
When to use which
Use Crossplane when the resource is high-volume and templated (buckets, queues, databases, IAM roles for apps), when consumers are developers who should not learn HCL, when drift correction is a compliance requirement, and when the consumer is already a Kubernetes workload that wants its credentials as a Secret.
Stay on Terraform for the bootstrap layer (accounts, VPCs, the EKS/ROSA cluster that runs Crossplane — you cannot have the cluster manage its own existence), for anything where a human must approve the exact diff before execution, for multi-cloud one-shots, and when your team's operational maturity on Kubernetes is low. A broken Crossplane provider is a debugging session in controller logs, not a Terraform error message.
The two coexist well: Terraform builds the landing zone and the cluster, Crossplane serves the application-level resources on top.
Security best practices
- No static AWS keys. Use IRSA / EKS Pod Identity /
WebIdentity. If you absolutely must use aSecret, source it from External Secrets Operator or Vault and rotate it; never commit it. - Least privilege per provider.
provider-aws-s3andprovider-aws-rdsget separateDeploymentRuntimeConfigs and separate IAM roles. The S3 provider has no business callingrds:DeleteDBInstance. - Multi-tenant isolation via ProviderConfig. One
ProviderConfigper environment/account, each referencing a different role. SetproviderConfigRefin the Composition, never from the Claim, and enforce it with a Kyverno or ValidatingAdmissionPolicy rule so no one creates raw managed resources directly. - RBAC on Claims, not on XRs. Developers get
create/get/list/deleteonobjectstoresin their namespace only. Nobody outside the platform team touchesComposition,CompositeResourceDefinition, or*.aws.upbound.io. - Encode guardrails in the Composition. Public access block, KMS encryption,
publiclyAccessible: false, mandatory tags — hardcoded in the base, not exposed as parameters. -
deletionPolicy: Orphan+ Usages on every stateful resource, with an AWS-side backstop (SCP or termination protection). - Protect connection secrets. Encrypt etcd at rest, keep provider-level secrets in
crossplane-system, and restrictgeton Secrets in tenant namespaces. - Verify packages. Pin provider and function images by digest, mirror them into your internal registry, and enable Crossplane's signature verification rather than pulling
:latestfrom the public internet. - Audit. Every change is a Kubernetes API call: ship audit logs, and alert on managed resources stuck in
Synced=False— that is usually an IAM denial.
Key takeaways
- Crossplane replaces the plan/apply transaction with a continuous control loop. You trade previewability for self-healing; choose per resource class, not per company.
- The XRD is your product API. Expose decisions (region, classification), not AWS fields. If the Claim is 40 lines long, the abstraction has failed.
- Compositions are where your security policy becomes executable: encryption, public access block, and tags are hardcoded, not optional.
-
kubectl get managedandcrossplane beta tracegive you fleet-wide infrastructure visibility that no Terraform state layout provides. -
deletionPolicy: Orphanon anything stateful, from day one. The default will eventually delete a production database. - Keep Terraform for the bootstrap layer and for changes that genuinely need a human reading a diff. Crossplane is a self-service platform, not a Terraform replacement.

![Humanoid Electrical Architecture — Ch.13: Diagnostics and OTA [EN]](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj8lqk47hi55f5ccyhwyg.png)