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.
Top comments (0)