For a decade the standard way to manage a cloud was Terraform: desired state in HCL, terraform plan, terraform apply, and the whole truth about your infrastructure in a state file. Crossplane proposes the opposite: cloud resources are Kubernetes objects, created by kubectl, rolled back by git revert, and kept in sync by the same perpetual reconcile loop a Deployment uses. In November 2026, with the v2.5 release, support for Crossplane v1.20 — the last 1.x line — ends. If you run 1.x, it is time to move; if you have only watched from a distance, look again — v2 fixed the pains that defined 1.x.
Table of contents
Open Table of contents
- Terraform: the truth lives in three places
- Crossplane 1.x: the right model, a heavy landing
- What changed in 2.0
- Terraform vs Crossplane v2
- What it takes to hand out an S3 bucket as one Kind
- Argo CD on top of Crossplane: two loops, one Git
- Migrating v1 → v2: the order and the rakes
- When Crossplane is not worth it
- How to verify it works
- Bottom line
Terraform: the truth lives in three places
Terraform has three stores of truth: Git holds the desired state (.tf files), the state file holds what was created and with which ids (usually S3, locked via DynamoDB), and the cloud holds how things actually are. The copies agree exactly at the moment of terraform apply. Then they diverge: an engineer edits a security group in the console, and plan finds out on the next run — tomorrow, on a busy project.
The symptoms are familiar to anyone who has lived next to a large state file:
- drift is only visible in the pipeline — between
planruns nothing reverts manual console edits, and “clean this morning” says nothing about the evening; - blast radius of one apply — the state file is one per environment: a botched module refactor or a stray
-targettakes down the project’s entire infrastructure in one run; - serialization through locks — two pipelines over one state file cannot run in parallel, and the platform team becomes the bottleneck;
- the state file must be guarded — a lost tfstate is rebuilt by hand-importing resources, and the secrets inside it (connection strings, keys) are a headache of their own.
The Kubernetes model solves this architecturally: a controller continuously compares observed state with desired state and reconciles the difference. Crossplane extends that loop to the cloud.
Crossplane 1.x: the right model, a heavy landing
In 1.x Crossplane worked like this: a provider (say provider-aws) registers a CRD for every cloud resource — thousands of managed resources, one object per bucket, VPC, or IAM user. On top sit compositions: a composite resource (XR) — your own Kind (“StaticSite”, “PostgresCluster”) — which a composition expands into managed resources. Terraform modules, except live and reconciled.
The landing was heavy: monolithic provider-aws pulled in 800+ CRDs — etcd and the API server suffered, and a provider upgrade was an event; XRs were cluster-scoped, and namespacing them through claims confused everyone; and only managed resources could be composed — an application would not fit next to its infrastructure in one object. v2 fixes exactly that.
What changed in 2.0
Crossplane 2.0 shipped in August 2025 — the biggest rework since the first release:
- compositions from any resource — a composition can now include anything: Deployments and Services, ConfigMaps, CRs from cert-manager or your own operator, on equal footing with cloud resources. Application and its cloud in one object: the “service X” XR describes the deployment, its S3 bucket, and its IAM user;
- provider functions — the provider is no longer a separate controller with CRDs but a function in the composition pipeline. provider-aws-family creates any AWS resource by calling the API directly; actual state lives in the XR’s status, not in thousands of etcd objects;
- namespaced XRs by default — XRs live in namespaces, plain RBAC grants access, claims are deprecated; cluster scope remains an explicit option;
- the function pipeline is the only path — built-in patch-and-transform is deprecated; compositions are assembled by functions: function-go-templating, function-kcl, function-cue;
- readiness for any resource — a composition can wait for a Deployment to become ready the way it used to wait for a managed resource.
Bonus — the CLI: crossplane beta render renders a composition locally without a cluster, crossplane beta validate checks an XR against policies. Start the migration there: render first, apply second.
Terraform vs Crossplane v2
| Terraform | Crossplane v2 | |
|---|---|---|
| Where the truth lives | state file in S3 | cluster objects |
| When things change | on pipeline runs | continuous reconcile |
| Drift | visible in plan | reconciled away |
| Team access | CI + state locks | kubectl + RBAC |
| Rollback | surgery on state | git revert |
| Speed of small changes | minutes: pipeline | seconds: reconcile |
| Module ecosystem | huge | smaller, growing |
| Requirements | a state backend | a cluster for the control plane |
What it takes to hand out an S3 bucket as one Kind
The goal: a team creates a StaticSite object in its own namespace — and gets an S3 bucket for static files plus an IAM user with keys, no ticket to the platform team. You need a cluster (managed is fine), Crossplane v2, and Argo CD. The AWS account is wired through a ProviderConfig — as with provider-aws, including IRSA/workload identity.
1. Core and functions. Install Crossplane, then two functions — go-templating to render and provider-aws-family to provision:
# functions.yaml — pin current versions from the registry
apiVersion: pkg.crossplane.io/v1beta1
kind: Function
metadata:
name: function-go-templating
spec:
package: xpkg.upbound.io/upbound/function-go-templating:v0.14.0
---
apiVersion: pkg.crossplane.io/v1beta1
kind: Function
metadata:
name: provider-aws-family
spec:
package: xpkg.upbound.io/upbound/provider-aws-family:v1.0.0
helm repo add crossplane-stable https://charts.crossplane.io/stable
helm install crossplane crossplane-stable/crossplane \
--namespace crossplane-system --create-namespace
kubectl apply -f functions.yaml
2. The Kind and its composition. The XRD declares the API, the Composition how to build it. Note the two pipeline steps — and that no CRDs for Bucket or User exist: the provider function takes those objects from desired state and calls the AWS API itself:
# xrd.yaml — namespaced XR by default, no claims needed
apiVersion: apiextensions.crossplane.io/v2
kind: CompositeResourceDefinition
metadata:
name: staticsites.platform.acme.io
spec:
group: platform.acme.io
names:
kind: StaticSite
plural: staticsites
defaultCompositionRef:
name: static-site-aws
versions:
- name: v1alpha1
served: true
referenceable: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
region:
type: string
default: eu-central-1
bucketName:
type: string
# composition.yaml
apiVersion: apiextensions.crossplane.io/v2
kind: Composition
metadata:
name: static-site-aws
spec:
compositeTypeRef:
apiVersion: platform.acme.io/v1alpha1
kind: StaticSite
mode: Pipeline
pipeline:
- step: render
functionRef:
name: function-go-templating
input:
apiVersion: gotemplating.fn.crossplane.io/v1beta1
kind: Template
resources:
- name: bucket
base:
apiVersion: s3.aws.upbound.io/v2beta1
kind: Bucket
spec:
forProvider:
region: "{{ .param.spec.region }}"
- name: ci-user
base:
apiVersion: iam.aws.upbound.io/v2beta1
kind: User
spec:
forProvider:
name: "{{ .param.spec.bucketName }}-ci"
- name: ci-keys
base:
apiVersion: iam.aws.upbound.io/v2beta1
kind: AccessKey
spec:
forProvider:
user: "{{ .param.spec.bucketName }}-ci"
- step: provision # no input needed: the provider function
functionRef: # picks its resources out of the desired
name: provider-aws-family
3. The consumer. The team (or Argo CD) applies an XR in its namespace:
# docs-acme-io.yaml
apiVersion: platform.acme.io/v1alpha1
kind: StaticSite
metadata:
name: docs-acme-io
namespace: team-docs
spec:
bucketName: docs-acme-io
writeConnectionSecretsToRef:
name: docs-acme-io-aws
The AccessKey pair lands in the XR’s connection details and is written to the referenced Secret — mount it into the CI runner that uploads the static files. For the “s3:PutObject on this bucket” policy, add a UserPolicy resource with the bucket ARN from its status.
Argo CD on top of Crossplane: two loops, one Git
The XRD, compositions, and XRs live in Git; Argo CD keeps the cluster equal to the repository, Crossplane keeps the cloud equal to the cluster. Two reconciliation loops, one source of truth.
Practices that save a week of debugging:
- app-of-apps for infrastructure — a root Application points at a folder of per-environment Applications, each at its own path: infrastructure lives in the same tree as the applications;
- ServerSideApply — XRD schemas are large; without SSA Argo hits the
last-applied-configurationlimit and silently skips the object; - sync waves — apply XRDs and compositions in an earlier wave than XRs, or the first sync fails with “no matches for kind”;
- prune: false at first — deleting an infrastructure Application with prune tears down the cloud; enable it deliberately;
- selfHeal: true — a manual
kubectl editof the XR reverts to Git; a console edit of the bucket reverts to desired state through Crossplane’s reconcile loop.
# infra-app.yaml — a leaf of the app-of-apps tree
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: static-site-docs
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/acme/infra.git
targetRevision: main
path: crossplane/static-site
destination:
server: https://kubernetes.default.svc
syncPolicy:
automated:
prune: false
selfHeal: true
syncOptions:
- ServerSideApply=true
- CreateNamespace=true
Migrating v1 → v2: the order and the rakes
The migration is designed to be gradual: legacy providers and managed resources keep working on v2, and nothing needs a big-bang rewrite.
- Core —
helm upgradeto v2; check that controllers and functions came up; - Compositions —
mode: Resourcesis no longer developed: move to the pipeline. function-patch-and-transform is a stopgap; go-templating or KCL is the destination; - Providers — replace monoliths with provider functions one composition at a time; a resource created as a managed resource must be “imported” into the function by aligning its external-name — otherwise you get a duplicate;
- Claims → namespaced XRs — move them together with RBAC: namespaced XRs need permissions in team namespaces, and cluster scope is now explicit only (
clusterScoped: true).
The three rakes people step on most: the apiextensions.crossplane.io/v2 API versions for XRDs and Compositions when updating manifests; deletionPolicy during a provider switch — a careless migration deletes what it should have inherited; and broken functionRefs when functions are not up yet at Argo CD’s first sync.
When Crossplane is not worth it
One VPS, one cloud account, one team — a control plane in Kubernetes is oversized: you would be maintaining a cluster for the sake of one bucket. Terraform in a single pipeline is more honest and cheaper: fewer moving parts, zero CRDs, modules for everything.
Crossplane pays off when: you have more than one environment or account; teams consume infrastructure self-service — “I need a bucket” instead of a three-day ticket; and the GitOps loop for applications already exists, so infrastructure naturally lives in the same tree.
How to verify it works
kubectl get staticsites -n team-docs
kubectl describe staticsite docs-acme-io -n team-docs # per-resource conditions
crossplane beta trace staticsite/docs-acme-io -n team-docs
aws s3 ls | grep docs-acme-io
argocd app get static-site-docs # Synced + Healthy
The drift test: delete the bucket in the AWS console — within the reconcile period Crossplane recreates it. Edit the XR by hand — selfHeal reverts the object to Git. Both loops close.
Bottom line
One VPS — stay with Terraform, it is cheaper and simpler. But once your infrastructure outgrows a single cloud account, a control plane inside Kubernetes is more honest and safer than a state file in S3: desired state is cluster objects with RBAC and history, drift is reconciled away continuously, and rollback is a git revert. The price is a cluster to maintain and an ecosystem younger than Terraform’s. Those on 1.x have no choice: the v1.20 EOL in November closes the question. And infrastructure in Kubernetes opens neighboring doors — see Zero Trust infrastructure access with Teleport covered separately.