Skip to main content

Crossplane Is Not Terraform in Kubernetes

·1444 words·7 mins
Aditya Wardianto
Author
Aditya Wardianto
DevOps Engineer @ Cube Asia
Crossplane - This article is part of a series.
Part 1: This Article

You look at Crossplane and think: this is just Terraform in Kubernetes. YAML instead of HCL, kubectl apply instead of terraform apply. Same declarative config, same cloud resources at the end. I thought the same thing when I first saw it.

Then I created an S3 bucket through Crossplane and deleted it from the AWS console just to see what would happen. Crossplane recreated it within seconds.

That moment changed how I think about infrastructure. The two tools share the same input format, but they operate in completely different categories. One is a compiler. The other is a runtime.

What’s actually wrong with the run-and-forget model?

Terraform does exactly what it was designed to do. It has an enormous provider ecosystem, mature state management, and it’s battle-tested at scale. The issue isn’t Terraform, it’s what happens after the process exits. Resources get modified out of band (tags via the console, policy changes, accidental deletions), and that drift accumulates silently until your next plan.1

Crossplane takes the opposite approach. Built on the Kubernetes reconciliation loop, it constantly watches resources and corrects drift back to your desired state.1

I came to Crossplane from Terragrunt. Hundreds of resources across environments, modular HCL, remote state locking. It worked for years. The question that cracked it open wasn’t whether Terraform can provision resources. It can. The question was what happens between runs.

What does “constantly watches resources” actually mean?

It means you move from an execution model to a control plane model. A control plane is software that manages other software: you declare desired state, it makes it happen, and it keeps it that way. AWS runs on this same pattern. When you call the EC2 API to create an instance, AWS’s control plane provisions the VM and reports back. If the instance terminates, the control plane detects the drift and acts.

Control planes are a core cloud native pattern.2 Crossplane generalizes it. You build a control plane for anything: cloud resources, SaaS APIs, DNS records, even other Kubernetes clusters.

The shift I had to internalize: IaC tools are compilers. They translate config into API calls, then stop. Crossplane is a runtime. It runs continuously, watching and correcting. Same declarative input, completely different behavior class.

So Crossplane runs inside Kubernetes?

Exactly. Crossplane is a set of controllers that extend Kubernetes with new custom resources. The neat part: you don’t write Go controller code. Crossplane provides the controllers; you describe what you want with YAML (or Python, or Go functions) and it handles the rest.1

Here’s what that looks like architecturally:

flowchart TB
    subgraph K8S["Kubernetes Cluster"]
        KAPI["Kubernetes API Server"] --> XCORE["Crossplane Core"]
        XCORE --> PROV["Provider Controller
(e.g. AWS)"] end USER["kubectl apply"] --> KAPI PROV --> CLOUD["Cloud Provider API"] CLOUD --> RES["S3 Bucket"]

For me, this clicked when I provisioned an S3 bucket I’d managed through Terragrunt for years. Same resource. But then I deleted it from the AWS console just to test, and Crossplane recreated it within seconds. That was the moment I stopped thinking about provisioning and started thinking about governing.

How do I create an S3 bucket with Crossplane?

You install a Crossplane provider, a package that adds support for a set of managed resources. The AWS S3 provider installs support for all AWS S3 resources, including Bucket.3

Here’s what a managed resource looks like:

apiVersion: s3.aws.m.upbound.io/v1beta1
kind: Bucket
metadata:
  namespace: default
  generateName: crossplane-bucket-
spec:
  forProvider:
    region: us-east-2

Create it with kubectl apply. The provider controller talks to the AWS API, provisions the bucket, and reports the status through the resource’s status field. When READY and SYNCED both show True, your bucket is live. Delete the custom resource and the bucket goes with it.3

Note: Before using providers, you need credentials. Crossplane providers authenticate using Kubernetes Secrets, IRSA (for EKS), or Workload Identity (for GKE). The setup varies per provider, but the pattern is consistent: create a Secret, reference it in a ProviderConfig.[^3]

That sounds like Terraform with a kubectl frontend. What’s actually different?

It’s the question I started with, and it deserves a real answer.

Both tools are declarative. The difference isn’t the syntax, it’s the execution model:

  • Terraform runs as a CLI or CI pipeline. It resolves config, plans changes, and applies them. Until the next run, it doesn’t know if a resource drifted.
  • Crossplane runs as a control plane. It continuously watches every managed resource. Delete a bucket through the console and Crossplane recreates it on the next reconciliation loop. Drift a tag and it’s corrected.

This makes Crossplane GitOps-native by design. You commit desired state to Git, ArgoCD or Flux syncs it to the cluster, and Crossplane reconciles from there. No CI pipeline required for provisioning changes. No credential management across multiple runners. No state file to store or lock.4

flowchart LR
    subgraph T["Terraform / Terragrunt"]
        TF_PLAN["terraform plan & apply"] --> TF_RES["Resources
created"] TF_RES -.->|"drift detected
next run"| TF_PLAN end subgraph C["Crossplane"] CP_APPLY["kubectl apply"] --> CP_WATCH["Provider
reconciles"] CP_WATCH -.->|"corrects drift
continuously"| CP_WATCH end

The distribution model is another difference worth understanding. With Terraform, you publish modules to a registry and pin versions through Git tags. Teams that forget to bump the ref stay on old versions. Crossplane takes a different approach: XRDs and Compositions are regular Kubernetes resources, installed through the same GitOps pipeline as your applications. The API server IS your registry. Run kubectl api-resources and XRDs list alongside built-in types like Deployments. Access control is RBAC. No separate registry credentials to manage.4

What if my team doesn’t want to write S3 bucket manifests? They want a higher-level “Storage” API.

This is where Crossplane’s composition engine comes in. Not in the hype sense. It literally changes what kind of tool you’re operating. You stop provisioning resources and start defining APIs that provision resources.

Composition lets you build custom APIs, Composite Resources (XR), backed by a pipeline of Composition Functions. You define the schema with a CompositeResourceDefinition (XRD) and wire up the logic in YAML, Python, KCL, or Go.5

This was the second click for me. Managed resources replace Terraform providers. Composition replaces Terraform modules. But composition does something modules can’t: your custom resource becomes a first-class API endpoint. A Database XR shows up in kubectl api-resources alongside Deployment and Service. The platform team defines the schema. App teams just create instances.

Here’s an App that provisions a Deployment, a Service, and an RDS instance from three lines of YAML:

apiVersion: example.crossplane.io/v1
kind: App
metadata:
  name: my-app
spec:
  image: nginx

Behind the scenes, Crossplane renders the full stack, all without writing a Kubernetes controller.5

flowchart TB
    subgraph A["App team creates"]
        APP["kind: App
image: nginx"] end subgraph P["Platform team owns"] C["Composition function
pipeline"] end subgraph R["Provider provisions"] S3["S3 Bucket"] RDS["RDS Instance"] end APP --> C C --> S3 C --> RDS

This separation defines platform engineering with Crossplane:6

  • Platform team owns the XRDs, Compositions, and provider configurations. They define what “a database” means for the organization.
  • Application team creates custom resources without caring whether the underlying resource is RDS, Cloud SQL, or on-prem PostgreSQL.

Beyond managed resources and composition, is there more?

Crossplane v2 introduced a third component: Operations. These are function pipelines that run to completion. Certificate monitoring, rolling upgrades, scheduled maintenance. Three flavors: Operation (run once), CronOperation (scheduled), and WatchOperation (triggered by resource changes).7

There’s also the Package Manager, which lets you install providers, functions, and entire control plane configurations across multiple clusters or regions.8

TL;DR
#

Crossplane is a control plane framework built on Kubernetes that lets you:

  • Provision cloud resources (S3 buckets, RDS instances, VPCs) using kubectl, with continuous drift correction instead of run-and-forget.
  • Build custom APIs using Composition: define your own XRDs and wire them to managed resources using Python, YAML, KCL, or Go functions.
  • Run operational tasks (certificate monitoring, upgrades) through function pipelines.
  • Package and distribute control plane configurations across multiple environments.

The key difference from traditional IaC tools: Crossplane never stops reconciling. Your desired state is the source of truth, and the control plane corrects any drift automatically.

Next Up
#

This was the conceptual overview. The next post explores Crossplane v2 composition: building custom APIs with XRDs, function pipelines, and the patterns that make platform engineering work.

The post after that closes the series with the full platform picture: Backstage as the developer portal, Crossplane as the provisioning engine, and ArgoCD as the GitOps delivery controller.

Have a good day!


  1. Web - Crossplane documentation - “What’s Crossplane?” ↩︎ ↩︎ ↩︎

  2. Web - Crossplane documentation - “Get Started” ↩︎

  3. Web - Crossplane documentation - “Get Started With Managed Resources” ↩︎ ↩︎

  4. Web - Crossplane documentation - “Configuring Crossplane with Argo CD” ↩︎ ↩︎

  5. Web - Crossplane documentation - “Get Started With Composition” ↩︎ ↩︎

  6. Web - Crossplane documentation - “Composition” ↩︎

  7. Web - Crossplane documentation - “Get Started With Operations” ↩︎

  8. Web - Crossplane documentation - “Packages” ↩︎

Crossplane - This article is part of a series.
Part 1: This Article