Skip to content
HoginHogin
Go back

Crossplane 2.0 and the coming v1.20 EOL: cloud infrastructure via GitOps instead of Terraform state

10 мин чтения

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

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.

Terraform: three copies of the truth and manual console edits

The symptoms are familiar to anyone who has lived next to a large state file:

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:

Crossplane 2.0: the composition pipeline — render by a function, provisioning by a provider function

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

TerraformCrossplane v2
Where the truth livesstate file in S3cluster objects
When things changeon pipeline runscontinuous reconcile
Driftvisible in planreconciled away
Team accessCI + state lockskubectl + RBAC
Rollbacksurgery on stategit revert
Speed of small changesminutes: pipelineseconds: reconcile
Module ecosystemhugesmaller, growing
Requirementsa state backenda 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.

GitOps over Crossplane: Argo CD reconciles the cluster, Crossplane reconciles the cloud

Practices that save a week of debugging:

# 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.

  1. Core — helm upgrade to v2; check that controllers and functions came up;
  2. Compositions — mode: Resources is no longer developed: move to the pipeline. function-patch-and-transform is a stopgap; go-templating or KCL is the destination;
  3. 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;
  4. 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.


Share this post:

Next Post
GPUs in Kubernetes without device plugins: how DRA changes accelerator allocation