DEV Community

Florient DOGBE
Florient DOGBE

Posted on

Crossplane vs Terraform: Provisioning S3 Buckets from Kubernetes Without Losing Control

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
kubectl get providers
kubectl get providerconfig.aws.upbound.io
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
kubectl apply -f claim.yaml
kubectl get objectstore -n team-billing
# NAME       SYNCED   READY   CONNECTION-SECRET   AGE
# invoices   True     True                        62s
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 a Secret, source it from External Secrets Operator or Vault and rotate it; never commit it.
  • Least privilege per provider. provider-aws-s3 and provider-aws-rds get separate DeploymentRuntimeConfigs and separate IAM roles. The S3 provider has no business calling rds:DeleteDBInstance.
  • Multi-tenant isolation via ProviderConfig. One ProviderConfig per environment/account, each referencing a different role. Set providerConfigRef in 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/delete on objectstores in their namespace only. Nobody outside the platform team touches Composition, 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 restrict get on 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 :latest from 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 managed and crossplane beta trace give you fleet-wide infrastructure visibility that no Terraform state layout provides.
  • deletionPolicy: Orphan on 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)