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 is the difference. The two tools share the same input format, but they operate in 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 handles state well with a broad provider ecosystem and is 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, then it makes it happen and 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.
Crossplane generalizes the pattern. You build a control plane for anything: cloud resources, SaaS APIs, DNS records, even other Kubernetes clusters.
The key difference: 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, different behavior class.
So Crossplane runs inside Kubernetes?
Crossplane is a set of controllers that extend Kubernetes with new custom resources. 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
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"]
Terraform connects to the cloud API once and returns. Crossplane stays connected forever.
How do I actually create a resource 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.2
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-2Create it with kubectl apply. The provider controller talks to the AWS API and provisions the bucket, then 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.2
That sounds like Terraform with a kubectl frontend. What’s actually different?
It’s the question I started with. The YAML looks the same, but the execution model changes what happens after you apply:
flowchart TB
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
That makes Crossplane GitOps-friendly: you commit desired state to Git, ArgoCD or Flux syncs it to the cluster, and Crossplane reconciles from there. No CI pipeline to maintain, no state file to store or lock.3
In Terraform, you’d reach for a module to standardize. What’s the Crossplane equivalent?
A Composition. Not a module, an API. The platform team defines a custom resource kind with its own schema. Teams create instances with kubectl apply. No module registry, no copy-paste across repos. Just a resource in kubectl api-resources.
Modules assume everyone shares your toolchain and your repo conventions. Compositions assume everyone speaks Kubernetes APIs. Same goal, different relationship. And it compounds when you move from a single S3 bucket to a full application stack: Deployments, Services, databases, each with its own wiring.
How does this scale beyond a single bucket?
Managed resources replace Terraform providers. Composition replaces Terraform modules. But a Composition registers your custom resource as a first-class API endpoint — an App XR shows up in kubectl api-resources alongside Deployment and Service. The platform team defines the schema. App teams just create instances.
The platform team creates two Crossplane resources to make this API available. First, the XRD defines the schema that specifies what fields the App resource accepts:
apiVersion: apiextensions.crossplane.io/v2
kind: CompositeResourceDefinition
metadata:
name: apps.example.crossplane.io
spec:
group: example.crossplane.io
names:
kind: App
plural: apps
versions:
- name: v1
served: true
referenceable: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
image:
type: string
replicas:
type: integer
default: 1
service:
type: object
properties:
port:
type: integer
default: 80
database:
type: object
properties:
enabled:
type: boolean
default: false
engine:
type: string
enum: [postgres, mysql]
default: postgresThis registers kind: App with the Kubernetes API server. kubectl api-resources shows apps alongside deployments and services. The schema validates inputs too: set a typo in engine and the API server rejects it before Crossplane ever sees it.
Second, the Composition tells Crossplane what to create when someone applies an App:
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: apps.example.crossplane.io
spec:
compositeTypeRef:
apiVersion: example.crossplane.io/v1
kind: App
mode: Pipeline
pipeline:
- step: render-resources
functionRef:
name: crossplane-contrib-function-go-templating
input:
apiVersion: go-templating.fn.crossplane.io/v1beta1
kind: GoTemplate
spec:
template: |
{{ $img := .observed.composite.resource.spec.image }}
{{ $replicas := .observed.composite.resource.spec.replicas | default 1 }}
{{ $svcPort := 80 }}
{{ if .observed.composite.resource.spec.service }}
{{ $svcPort = .observed.composite.resource.spec.service.port | default 80 }}
{{ end }}
{{ $db := .observed.composite.resource.spec.database }}
{{ $dbEnabled := false }}
{{ if $db }}{{ $dbEnabled = $db.enabled }}{{ end }}
{{ $dbEngine := "postgres" }}
{{ if $db }}{{ if $db.engine }}{{ $dbEngine = $db.engine }}{{ end }}{{ end }}
---
apiVersion: apps/v1
kind: Deployment
spec:
replicas: {{ $replicas }}
template:
spec:
containers:
- name: app
image: {{ $img }}
---
apiVersion: v1
kind: Service
spec:
ports:
- port: {{ $svcPort }}
{{ if $dbEnabled }}
---
apiVersion: rds.aws.upbound.io/v1beta1
kind: Instance
spec:
forProvider:
region: us-east-2
engine: {{ $dbEngine }}
{{ end }}The template reads fields from the XR (spec.image, spec.replicas, etc.) and renders them into concrete resources. The database block is conditional: set database.enabled: false and Crossplane skips provisioning RDS entirely. No database resource exists, no credentials to manage.
With both resources installed, an application team creates an App instance. With database enabled, the full spec is a dozen lines:
apiVersion: example.crossplane.io/v1
kind: App
metadata:
name: my-app
spec:
image: nginx
replicas: 3
service:
port: 8080
database:
enabled: true
engine: mysqlDrop the database block and the Composition skips RDS:4
apiVersion: example.crossplane.io/v1
kind: App
metadata:
name: my-app
spec:
image: nginx
replicas: 2
service:
port: 80That’s the split:
- Platform team owns the XRDs and Compositions, plus provider configuration. 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.
And if someone deletes that RDS instance from the console, it comes back. The same reconciliation loop treats it as drift, whether it’s an S3 bucket or a database. The control plane doesn’t care what you delete; it cares about the state you declared.1
TL;DR #
Crossplane runs inside Kubernetes and never stops reconciling:
- 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.
Crossplane never stops reconciling. It corrects drift against your desired state automatically.
Next Up #
The next post goes deeper into Crossplane v2 composition: building custom APIs with XRDs and function pipelines. The third post ties it together — Backstage as the portal, Crossplane as the engine, ArgoCD as the delivery pipeline. Have a good day!