Crossplane Composition Functions: Internal Developer Platform

Crossplane Composition Functions: Internal Developer Platform

Crossplane Composition Functions: Internal Developer Platform

Last Updated: September 2026 | Verified against Crossplane v2.4 documentation

Most platform teams that adopt Kubernetes for infrastructure hit the same wall: the first ten abstractions are easy, and the eleventh is a 900-line YAML file nobody dares touch. Crossplane composition functions are the mechanism the project built to escape that wall. Instead of patching fields between manifests, you write a pipeline of small programs, each of which receives the observed world plus the desired state so far and returns a better desired state. That single design decision is what turns Crossplane from a clever CRD generator into a credible engine for an internal developer platform (IDP).

It matters more in September 2026 than it did when this post first went up, because Crossplane v2 changed the shape of the whole API: composite resources are namespaced, claims are gone, compositions can produce ordinary Kubernetes objects, and a new Operations primitive reuses the same function machinery for day-two work. The project also graduated in the Cloud Native Computing Foundation (CNCF), which changes the risk calculation for adopters.

You will leave with a working mental model of the function contract, a complete v2 example (XRD, composition, Go-templating and Python steps), a local testing workflow, and an honest comparison with Terraform and OpenTofu.

What this covers: what changed since the last version of this post, the v2 reference architecture, the function protocol, a full database platform example, testing, Operations, failure modes, and a rollout checklist.

What Changed for September 2026

This is a full rewrite. The earlier version described Crossplane as it looked in the v1.x era, and several claims in it are now wrong or misleading. Here is the delta that matters.

Crossplane v2 is the current line. Version 2.0 was announced on August 13, 2025, and the project documentation now lists v2.4 as the latest release. The project ships on a quarterly cadence, with the three most recent minors maintained and each release supported for roughly nine months. The v1.20 release, the last v1 minor, has an announced end of life in November 2026, so v1 users are on a clock.

Composite resources are namespaced by default. The CompositeResourceDefinition (XRD) gained a scope field that defaults to Namespaced, with Cluster still available. The XRD apiVersion moved to apiextensions.crossplane.io/v2. Managed resources from the v2-era providers are namespaced too, and their API groups gained an .m. segment, for example s3.aws.m.upbound.io.

Claims are gone for the new scopes. The old post taught a claim-to-XR flow, PostgresInstance claim producing XPostgresInstance. In v2 the developer creates the XR directly in their own namespace, and claims survive only for legacy cluster-scoped XRDs (scope LegacyCluster).

Native patch and transform is removed. mode: Resources no longer exists. Every Composition is a mode: Pipeline composition, and the old P&T logic lives in function-patch-and-transform. The CLI includes crossplane beta convert pipeline-composition to migrate.

Compositions can compose anything. A v2 XR can create Deployments, Services, and third-party custom resources next to cloud resources, which is what makes application-level platform APIs realistic.

Operations arrived, still alpha. Operation, CronOperation, and WatchOperation run function pipelines to completion, on a schedule, or in reaction to resource changes. They require the --enable-operations flag, and the documentation warns that the feature may change or be dropped.

New function capabilities. Functions can declare required resources up front instead of round-tripping, and v2.2 added an alpha Pipeline Inspector, CEL validation outside spec in XRDs, and RequiredSchemas so a function can ask for the OpenAPI schema of any kind. v2.4 added watching of required resources so that a change triggers immediate XR reconciliation.

CNCF graduation and the Upbound relationship. Crossplane graduated in the CNCF on November 6, 2025. Upbound continues to sell Upbound Crossplane (UXP), a distribution built on the open-source project, while community providers are published under crossplane-contrib.

A full list of corrections to the earlier post appears in the review log shipped with this rewrite. The short version: function timeouts, the “sidecar over Unix socket” model, several field names, and a security-hostile example were wrong, and they are fixed below.

Context and Background

Infrastructure as code solved repeatability, but it left ownership unresolved. Terraform, and now its open-source fork OpenTofu, gives every consumer a copy of the graph and a state file to guard. That is fine for a team that owns its stack. It is awkward when a platform team wants to offer “a production Postgres” as a product to forty application teams, each of whom should not need to know about subnet groups, parameter groups, KMS keys, or backup windows.

Crossplane approaches the problem from the opposite direction. It runs inside a Kubernetes cluster, turns cloud APIs into Kubernetes resources through providers, and lets the platform team define higher-level custom resources whose implementation is hidden behind a Composition. Because everything is a Kubernetes object, you inherit the control-loop model: continuous reconciliation, drift correction, RBAC, admission control, and GitOps delivery. If you already run Argo CD or Flux, as covered in our Argo CD and Flux GitOps tutorial, the platform API is just another set of manifests in Git.

The first generation of Compositions used patch and transform: a declarative list of resource templates plus field-to-field patches. It worked for simple cases and collapsed for anything with conditionals, loops, or cross-resource logic. A database with an optional read replica, or a bucket policy that depends on a list of team members, forced awkward workarounds. The community’s answer, introduced as beta in Crossplane v1.14 in late 2023, was composition functions: external gRPC services that own the logic. The earlier version of this post said functions were generally available in v1.14, which was inaccurate; v1.14 shipped them as beta, and the old native mode was deprecated afterwards and removed in v2.

The platform engineering movement supplies the demand. The idea, described in our comparison of Backstage, Port, and Cortex, is to give developers a self-service portal on top of a stable API. A portal can render forms and catalogs, but something must sit behind it that actually creates infrastructure safely. Crossplane is one of the leading candidates for that “control plane behind the portal” role, and the CNCF’s Crossplane graduation announcement is a useful proxy for maturity: the project reports contributions from over 3,000 people across more than 480 companies and 70 or more public adopters, and it has completed two security audits.

The trade is real, though. You are adopting a control plane you must operate, upgrade, and secure, and you are writing software (functions) rather than declarative configuration. The rest of this post is about deciding whether that trade suits you and, if it does, doing it well.

Reference Architecture: A Function-Driven Platform API in Crossplane v2

Crossplane composition functions are gRPC services that a Composition calls, in order, to compute the desired set of composed resources for a composite resource (XR). Crossplane observes the world once, hands each function the observed state and the desired state accumulated so far, then applies the final result with server-side apply. Functions run as pods in the cluster, not as sidecars, and traffic to them is encrypted and authenticated.

Crossplane composition functions reference architecture for an internal developer platform on Kubernetes

Figure 1: A v2 platform API. A namespaced XR is reconciled by Crossplane core, which calls the Composition’s function pipeline and applies the result to namespaced managed resources and Kubernetes objects.

Read the diagram left to right and top to bottom. The XRD defines the API, including its scope and schema. A developer, or a portal acting on their behalf, creates an XR in a team namespace. Crossplane core notices it, selects a Composition, and executes the pipeline. Whatever the pipeline returns becomes the contract with the rest of the cluster: managed resources handled by provider pods, plus any native Kubernetes objects. Observed state flows back, and the cycle repeats on every change and periodically for drift.

The Three API Objects You Own

A platform team owns three kinds of object. The XRD declares the API: its group, kind, versions, OpenAPI schema, and scope. The Composition binds one XRD kind to an implementation: a mode: Pipeline list of steps, each pointing at a Function. The Function is a package resource, installed like a provider, that pulls an OCI image and runs it as a Deployment.

Separating them is the point. Developers see only the XRD’s schema, which is effectively your product surface. You can ship several Compositions for the same XRD, such as aws-rds and gcp-cloudsql, and select between them with labels or a compositionRef. Swapping implementation, say from a single-AZ instance to Aurora, is a Composition change and requires no developer action, which is the same decoupling that makes any good platform API durable.

Why Namespaced XRs Change the Design

In v1, XRs were cluster-scoped, so the platform had to bolt on claims to give tenants a namespaced handle. That worked but doubled the object count and produced awkward semantics around deletion and connection secrets. In v2 the XR itself is namespaced. Kubernetes RBAC now expresses tenancy directly: grant a team create on databases.platform.example.org in their namespace, and they can request databases only there.

The managed resources follow. Provider v2 MRs are namespaced, and Crossplane places composed resources in the XR’s namespace, so you must not hardcode a namespace in a template. Provider configuration is still referenced with providerConfigRef, and namespaced MRs can point at either a ProviderConfig or a ClusterProviderConfig. This gives you a workable answer to the multi-tenancy question that used to require virtual clusters; for the harder isolation cases, see our guide to Kubernetes multi-tenancy with vCluster.

Two practical caveats apply. XR fields under spec.crossplane and status.crossplane, and status.conditions, are reserved by Crossplane and cannot appear in your schema. And because v2 removed XR connection details, secrets must be composed explicitly as Kubernetes Secrets, typically from the managed resource’s connection secret or via the External Secrets Operator pattern from our secrets management architecture guide.

Where the Logic Should Live

The most common design mistake is treating functions as a place to hide business logic that belongs in policy or in a portal. A useful split follows from the reconciliation model. The XRD schema and its CEL validations reject bad input at admission time. Admission policy engines, as compared in our Kyverno versus OPA Gatekeeper article, enforce organization-wide rules that are not specific to one API. The function pipeline turns valid intent into resources. Anything that depends on the live state of composed resources, such as “add the read replica only after the primary is ready”, belongs in the pipeline because only the pipeline sees observed state.

Keeping that discipline gives you an important property: every function is a pure-ish transformation from (observed, desired, input, context) to desired. Pure functions are testable without a cluster, and that is the entire testing story we will build later.

The Function Contract in Detail

A Crossplane function is a gRPC server implementing one RPC, RunFunction. Understanding the messages precisely prevents most of the production bugs teams hit, and it corrects several details in the earlier version of this post.

Request and Response Messages

The request carries five things that matter. observed holds the current XR and any composed resources Crossplane found, keyed by the resource name you gave them in the pipeline. desired holds the desired state accumulated by earlier steps. input is the function-specific configuration written in the Composition step. context is a scratch map that Crossplane passes to later steps in the same pipeline and discards afterwards. credentials supplies secrets a function declared it needs, such as an API token.

Newer Crossplane versions also send required_resources and advertise capabilities in RequestMeta.capabilities. The response returns the updated desired state, a results list of severity-tagged messages (fatal, warning, normal), updated context, optional requirements asking Crossplane to fetch more resources, and TTL hints. A fatal result stops the pipeline for that reconcile and surfaces on the XR’s conditions; Crossplane retries on the next pass.

The earlier post described observed_composite and desired_composite request fields and a boolean fatal flag on the response. Those names were inaccurate. The real messages are observed and desired, each with composite and resources sub-fields, and fatality is expressed through a result with SEVERITY_FATAL.

Sequence of a Crossplane composition function pipeline calling three functions and applying the final desired state

Figure 2: One reconcile. Crossplane observes once, calls each function with the accumulated desired state, then server-side applies the final result and updates XR status.

The Copy-Forward Rule

The most important behavioral rule in the docs is easy to miss. Each function must copy all desired state it received into its response. If a function omits a resource that a previous step added, Crossplane treats it as no longer desired and deletes it. SDK helpers do this for you when you start from the request’s desired state, but a hand-rolled function that builds a fresh response is a classic way to delete a production database.

This rule also explains the composition-resource-name concept. Each composed resource in desired state has a stable name (a map key, or the annotation gotemplating.fn.crossplane.io/composition-resource-name in Go templating). That name is how Crossplane matches desired to observed across reconciles. Rename it and Crossplane sees a deletion and a creation.

Transport, Runtime, and Timeouts

Functions are installed as Function packages. Crossplane’s package manager creates a Deployment and Service for each, and core calls it over gRPC with mutual TLS, as the documentation puts it, encrypting and authenticating all communication with function pods. This is why the earlier claim of a sidecar reached over a Unix socket at /tmp/crossplane-function-<hash>.sock was wrong. Sockets do appear elsewhere, notably in the v2.2 Pipeline Inspector, which forwards request and response pairs to a Unix socket for debugging, but they are not the production call path.

Because functions are ordinary pods, you tune them like any workload: replicas for availability, resource requests, and a DeploymentRuntimeConfig for node selectors, tolerations, and service accounts. That configuration type replaces the removed ControllerConfig. A single-replica function is a single point of failure for every XR that uses it, so production platforms run at least two replicas and spread them.

I could not verify a fixed 60-second per-call timeout in current documentation, so treat the number in the old post as unconfirmed. What is stable advice is behavioral: keep functions fast and side-effect free, and never call slow cloud APIs from a function. Return desired resources and let providers do the slow work asynchronously.

Requirements, Schemas, and Caching

Functions frequently need data that is not in the XR, such as a cluster-wide EnvironmentConfig with account IDs and subnet lists. Crossplane offers two mechanisms. With bootstrap requirements, the Composition step declares requirements.requiredResources and Crossplane fetches them before the first call, which is cheaper. With dynamic requests, the function returns requirements in its response, Crossplane fetches and re-calls it, iterating up to five times until the function returns the same requirements twice. Resources can be selected by name or label, cluster-scoped or namespaced.

Since v2.4, changes to required resources can trigger immediate XR reconciliation, so an update to a shared configuration object propagates without waiting for the next poll. There is also an alpha function response cache enabled with --enable-function-response-cache, using TTLs from the function with a configurable maximum (default 24 hours). Treat it as an optimization for expensive pure functions, not something to build correctness on.

Walk-through: A Self-Service Database Platform on Crossplane v2

The example below replaces the old post’s CloudSQL walk-through, which had three problems: it used a legacy provider API group, it opened the database to 0.0.0.0/0, and it referenced a function-vault package I could not verify exists. The rewrite uses an AWS RDS PostgreSQL database with the namespaced provider, keeps the database private and encrypted, and reads shared settings from an EnvironmentConfig. Treat provider field names as illustrative and validate them against the provider version you install, because the schema changes between releases.

Crossplane function pipeline stages from XR spec through templating and policy functions to auto-ready

Figure 3: The five-step pipeline used in this walk-through. Context is shared between steps; the final desired state is applied and anything omitted is deleted.

Step 1: Define the API with a v2 XRD

Design the schema as a product, not a passthrough of every provider field. Three t-shirt sizes, an environment, and a version are usually enough to start.

apiVersion: apiextensions.crossplane.io/v2
kind: CompositeResourceDefinition
metadata:
  name: postgresdatabases.platform.example.org
spec:
  scope: Namespaced
  group: platform.example.org
  names:
    kind: PostgresDatabase
    plural: postgresdatabases
  versions:
  - name: v1alpha1
    served: true
    referenceable: true
    schema:
      openAPIV3Schema:
        type: object
        properties:
          spec:
            type: object
            properties:
              size:
                type: string
                enum: ["small", "medium", "large"]
                default: small
              environment:
                type: string
                enum: ["dev", "staging", "prod"]
              engineVersion:
                type: string
                default: "16"
              storageGB:
                type: integer
                minimum: 20
                maximum: 1000
                default: 20
            required: ["environment"]
          status:
            type: object
            properties:
              endpoint:
                type: string
              sizeClass:
                type: string

Notice what is absent: no claimNames, no parameters wrapper (the old post nested everything under spec.parameters, a v1 convention that is no longer necessary), and no cloud-specific fields. The developer cannot ask for a public database because the schema offers no way to express it. That is API design as guardrail, and it is cheaper than policy enforcement after the fact.

Step 2: Install the Functions

Functions are packages. Use fully qualified registry names, since v2 removed the default registry flag. Pin versions; the version tags below are placeholders that show the syntax; check each function’s releases page for the current tag.

apiVersion: pkg.crossplane.io/v1
kind: Function
metadata:
  name: function-environment-configs
spec:
  package: xpkg.crossplane.io/crossplane-contrib/function-environment-configs:v0.4.0
---
apiVersion: pkg.crossplane.io/v1
kind: Function
metadata:
  name: function-go-templating
spec:
  package: xpkg.crossplane.io/crossplane-contrib/function-go-templating:v0.10.0
---
apiVersion: pkg.crossplane.io/v1
kind: Function
metadata:
  name: function-auto-ready
spec:
  package: xpkg.crossplane.io/crossplane-contrib/function-auto-ready:v0.5.0

Pinned tags matter because function updates change behavior for every Composition that references them. Treat function versions the way you treat provider versions: promote through environments with a lockfile, not through latest.

Step 3: Write the Composition Pipeline

The pipeline first loads shared settings, then renders resources, then marks readiness. A Python step for sizing policy is added in the next section.

apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
  name: postgresdatabase-aws
  labels:
    provider: aws
spec:
  compositeTypeRef:
    apiVersion: platform.example.org/v1alpha1
    kind: PostgresDatabase
  mode: Pipeline
  pipeline:
  - step: load-environment
    functionRef:
      name: function-environment-configs
    input:
      apiVersion: environmentconfigs.fn.crossplane.io/v1beta1
      kind: Input
      spec:
        environmentConfigs:
        - type: Reference
          ref:
            name: aws-platform-defaults
  - step: render-database
    functionRef:
      name: function-go-templating
    input:
      apiVersion: gotemplating.fn.crossplane.io/v1beta1
      kind: GoTemplate
      source: Inline
      inline:
        template: |
          {{- $xr := .observed.composite.resource -}}
          {{- $env := index .context "apiextensions.crossplane.io/environment" -}}
          {{- $classes := dict "small" "db.t4g.medium" "medium" "db.m6g.large" "large" "db.m6g.2xlarge" -}}
          {{- $multiAZ := eq $xr.spec.environment "prod" -}}
          ---
          apiVersion: rds.aws.m.upbound.io/v1beta1
          kind: Instance
          metadata:
            annotations:
              gotemplating.fn.crossplane.io/composition-resource-name: db
          spec:
            forProvider:
              region: {{ $env.region }}
              engine: postgres
              engineVersion: {{ $xr.spec.engineVersion | quote }}
              instanceClass: {{ index $classes $xr.spec.size }}
              allocatedStorage: {{ $xr.spec.storageGB }}
              storageEncrypted: true
              publiclyAccessible: false
              multiAz: {{ $multiAZ }}
              username: dbadmin
              autoGeneratePassword: true
              passwordSecretRef:
                name: {{ $xr.metadata.name }}-master
                key: password
              dbSubnetGroupName: {{ $env.dbSubnetGroup }}
              vpcSecurityGroupIds:
              - {{ $env.dbSecurityGroup }}
              backupRetentionPeriod: {{ if $multiAZ }}14{{ else }}3{{ end }}
              skipFinalSnapshot: {{ not $multiAZ }}
            writeConnectionSecretToRef:
              name: {{ $xr.metadata.name }}-conn
            providerConfigRef:
              kind: ClusterProviderConfig
              name: default
          ---
          apiVersion: platform.example.org/v1alpha1
          kind: PostgresDatabase
          status:
            sizeClass: {{ index $classes $xr.spec.size }}
  - step: ready
    functionRef:
      name: function-auto-ready

Several details deserve comment. The template reads the XR from .observed.composite.resource, which fixes the old post’s .observed.composite.spec path. The EnvironmentConfig data arrives in the pipeline context under the apiextensions.crossplane.io/environment key. The composed resource has a stable name via the annotation. And the second document in the template does not create anything; a document whose apiVersion and kind match the XR is how function-go-templating writes to XR status.

The example also fixes a real bug pattern in the old post. It looked up a password from Vault in one step and then referenced a hardcoded secret name in the next, so the “fetched” value never reached the resource. Here the provider generates the password and writes it to a Secret whose name is derived from the XR. If you need an external secret manager, integrate at the Secret layer with External Secrets Operator instead of pulling values through a function, which would put plaintext into pipeline context and, with the Pipeline Inspector on, into debug streams.

Step 4: The Developer Experience

The developer applies one small manifest in their namespace.

apiVersion: platform.example.org/v1alpha1
kind: PostgresDatabase
metadata:
  name: analytics
  namespace: data-engineering
spec:
  size: medium
  environment: prod
  storageGB: 200

Crossplane composes the RDS instance in data-engineering, applies it, watches readiness, and reports on the XR. The developer inspects it with kubectl get postgresdatabase -n data-engineering, and the platform team traces the full tree with crossplane beta trace, which as of v2.2 supports bulk queries and a live watch flag. A connection secret named analytics-conn lands in the same namespace, which is exactly how the application Deployment consumes it.

Total developer-facing surface: four fields. Total platform-owned surface: the RDS instance, the encryption, the network placement, the backup policy, and whatever you add tomorrow, such as a CloudWatch alarm or a parameter group, without touching a single developer manifest.

Going Beyond Templates: A Python Function for Policy and Sizing

Go templating handles rendering. Eventually you need real code: validating combinations, calling an internal catalog, computing values. That is where SDK-based functions earn their keep. Crossplane maintains SDKs for Go and Python, both explicitly labelled beta with no stable API guarantee until v1.0.0, and community functions like function-kcl offer a third route with the KCL language.

Here is a sketch of a Python step that enforces a rule the schema cannot express: production databases must be at least medium, and a cost-center label is required on the XR.

from crossplane.function import resource, response
from crossplane.function.proto.v1 import run_function_pb2 as fnv1
import grpc


class FunctionRunner:
    async def RunFunction(self, req: fnv1.RunFunctionRequest,
                          _: grpc.aio.ServicerContext) -> fnv1.RunFunctionResponse:
        rsp = response.to(req)          # copies desired state forward
        xr = req.observed.composite.resource
        spec = xr["spec"]
        labels = xr["metadata"].get("labels", {})

        if spec["environment"] == "prod" and spec["size"] == "small":
            response.fatal(rsp, "prod databases must be medium or large")
            return rsp

        if "cost-center" not in labels:
            response.fatal(rsp, "metadata.labels.cost-center is required")
            return rsp

        response.normal(rsp, "policy checks passed")
        return rsp

The important line is response.to(req), which starts the response from the request’s desired state and therefore honors the copy-forward rule. A fatal result stops this reconcile and appears on the XR as a condition, giving the developer an actionable message. Package the function with crossplane function generate and the project tooling, push it as an OCI image, and reference it from the Composition like any other function.

Prefer CEL validation in the XRD for rules that depend only on the XR’s own fields. Since v2.2 those rules can extend beyond spec, for example to enforce naming conventions on metadata, and they run at admission time with clear errors. Reserve code functions for rules that need information the API server does not have.

Choosing a Function Language

The choice among Go templating, KCL, Python, and Go is mostly about who will maintain it. Templates are the fastest to write and the easiest to review for people who already read YAML, but they scale poorly: whitespace bugs, no type checking, and logic buried in {{ }} blocks. Python lowers the barrier for teams without Go skills and is convenient for calling libraries. Go gives the best performance, type safety, and unit-test story, at the cost of a build pipeline. KCL is schema-typed and declarative, which suits teams that want validation built into the language. A newer entrant, function-kro, announced in March 2026, embeds kro’s YAML plus CEL authoring model as a Crossplane pipeline step, so you write dependency-aware resource graphs with ${...} expressions instead of templates.

A pragmatic rule: start with templating for resource shapes, add a code function only for logic you would otherwise be afraid to put in a template, and keep each function to one responsibility so it can be tested, versioned, and reused across Compositions.

Testing Composition Functions Before They Touch a Cluster

Testability is the best argument for functions over giant templates, and Crossplane ships tooling to exploit it. The crossplane composition render command runs your Composition locally, calling the real function containers through Docker, and prints the resources that would be created. No control plane is involved.

crossplane composition render xr.yaml composition.yaml functions.yaml \
  --extra-resources=environment.yaml \
  --observed-resources=observed.yaml \
  --include-full-xr

Function-development iteration is faster with the development runtime, which connects to a function running on localhost:9443 so you can attach a debugger instead of rebuilding an image. The flags worth knowing are --observed-resources to mock managed resources that already exist (essential for testing readiness and second-reconcile logic), --required-resources to feed data your function requests, --function-credentials for functions that need secrets, and --context-values to inject pipeline context. Check crossplane composition render --help for the exact flag spelling in your CLI version, since the CLI now releases on its own schedule at cli.crossplane.io.

A Test Strategy That Scales

Three layers give good coverage without a cluster for most of it.

Schema tests. crossplane resource validate checks rendered output against provider and XRD schemas without a cluster. This catches misspelled fields such as multiAZ versus multiAz, which are otherwise discovered as a Synced=False condition in production.

Golden-file render tests. For each XR fixture, render and diff against a checked-in expected output. Run these in CI on every pull request. Include fixtures for the awkward second reconcile, where observed contains composed resources with status, because that is where readiness and status-propagation bugs live.

Cluster smoke tests. In a kind cluster, install Crossplane, apply the Composition with a fake or stubbed provider, and assert the XR reaches Ready. This layer is slow and should be small. Its job is to prove that packaging, RBAC, and function connectivity work, not to re-test logic.

Two debugging tools are worth adding to your runbook. crossplane beta trace prints the XR-to-composed-resource tree with conditions, which turns most “why is this stuck” questions into a one-command answer. The v2.2 Pipeline Inspector, currently alpha, intercepts every request and response and forwards them to a Unix socket, so you can see exactly what each step received and returned. Enable it in development and consider carefully before routing it to any store in production, because requests can contain secrets that functions handle.

Operations: Reusing Functions for Day-Two Work

The v2 feature that most changes platform scope is Operations. A Composition continuously reconciles desired state. Many platform tasks are not that: take a backup, rotate a certificate, upgrade a fleet in waves, validate a configuration after a change. Previously teams solved these with CronJobs, custom controllers, or Argo Workflows glued to Crossplane objects. Operations run the same function pipeline concept once to completion.

There are three kinds. An Operation runs once. A CronOperation runs on a schedule. A WatchOperation runs when a watched resource changes. All use apiVersion: ops.crossplane.io/v1alpha1, take mode: Pipeline, and support a retryLimit. Functions must declare an operation capability in their metadata to be usable. Operations force server-side apply and do not set owner references on what they change, which is deliberate: they act on things other controllers own.

Good candidates include a weekly snapshot check for every prod database, a WatchOperation that annotates new namespaces with default quotas, and a rolling minor-version upgrade that pauses on failed health checks. Bad candidates include anything you cannot tolerate breaking: the feature is alpha, requires --enable-operations, and the docs state that Crossplane may change or drop it at any time. Pilot Operations on non-critical automation and keep the equivalent logic portable.

Fitting Crossplane into the Wider IDP

A control plane is only one layer of a platform. Figure 4 shows a common layering that has emerged among teams using Crossplane with GitOps and a portal.

Internal developer platform layers with Crossplane composition functions between GitOps delivery and cloud providers

Figure 4: A layered IDP. Portal and GitOps deliver XRs, admission policy guards the API, and Crossplane compositions and functions realize infrastructure through providers.

The portal, whether Backstage or a commercial alternative, renders a form from the XRD schema and commits a manifest to Git. Our Backstage 1.30 production setup guide covers the portal side. Argo CD or Flux syncs the manifest to the cluster. An admission policy engine enforces cross-cutting rules such as required labels. Crossplane then does the real work, and status flows back to the portal by reading the XR’s conditions.

Two integration details cause trouble. First, sync waves: the XRD, Function packages, and Composition must exist and be healthy before any XR is applied, or the GitOps tool will report errors on first sync. Order them in separate Applications or use sync-wave annotations. Second, health assessment: teach your GitOps tool to interpret XR Ready and Synced conditions, otherwise it will call a half-provisioned database “healthy”.

Multi-Cluster and Multi-Cloud Shape

Crossplane is not multi-cloud magic. A single Composition tied to one provider’s kinds is portable only in the sense that the XRD stays stable while implementations differ. The realistic pattern is one XRD with several Compositions selected by label, such as provider=aws or provider=gcp, each implemented separately. Developers state intent; the platform picks the implementation per environment.

For fleets, run a small management cluster that hosts Crossplane and creates or manages workload clusters, as we describe in multi-cluster management with Cluster API. Keep provider credentials in the management cluster only, use workload identity rather than long-lived keys where the provider supports it, and remember that the v2.2 ImageConfig runtime dependency settings can inject such configuration into provider chains.

Crossplane Versus Terraform and OpenTofu

The honest answer to “Crossplane or Terraform?” is that they solve overlapping but different problems, and many mature platforms use both. The decision matrix below reflects design differences rather than a benchmark.

Dimension Crossplane v2 Terraform or OpenTofu
Execution model Continuous reconciliation in a cluster Plan and apply on demand, usually in CI
Drift handling Automatic correction by controllers Detected at next plan, fixed at next apply
State Kubernetes API objects, no separate state file State file plus backend and locking
Consumer interface Kubernetes API, RBAC, kubectl, GitOps Modules with variables, run via pipelines
Abstraction language XRDs plus functions in Go, Python, KCL, templating HCL modules
Preview of changes Weaker; render locally, no native plan First-class plan output
Operational burden You run and upgrade a control plane Mostly runner and state backend
Ecosystem breadth Large, but many providers are generated from Terraform providers Largest provider and module ecosystem
Best fit Self-service APIs for many tenants, always-on convergence Foundational infrastructure, one-off or slow-changing stacks

A detail that surprises newcomers: many Crossplane providers are generated by Upjet from Terraform providers, so resource coverage tracks Terraform’s, and behavior quirks come along. That means Crossplane is not free of the Terraform provider ecosystem; it changes the control loop around it.

Where Terraform or OpenTofu still wins is preview. A reviewer can read a terraform plan; a Crossplane change shows up as a diff in Git and a render output, with no built-in plan against live state. For foundation layers such as accounts, networks, and the clusters that host Crossplane itself, Terraform or OpenTofu is often the better tool. Our OpenTofu migration tutorial and the Terraform 1.16 versus OpenTofu 1.13 comparison cover that side. A common split: IaC provisions the landing zone and management cluster; Crossplane serves tenant-facing, self-service resources on top.

Migrating from Crossplane v1

If you run v1, plan the move before the v1.20 end-of-life. The upgrade guide is strict about sequence: upgrade one minor at a time through v1.20, v2.0, and v2.1, rather than skipping.

Start by running crossplane beta upgrade check, which scans for removed features. Convert mode: Resources compositions with crossplane beta convert pipeline-composition, and replace ControllerConfig with DeploymentRuntimeConfig using the matching convert command. Replace external secret stores with Kubernetes secrets or External Secrets Operator, and recreate any XR connection details as explicit Secrets. Use fully qualified package names everywhere.

Existing v1 XRs keep working: legacy XRDs default to scope: LegacyCluster, claims stay functional, and cluster-scoped managed resources are unchanged. There is no automated conversion from cluster-scoped to namespaced resources, so treat moving to the new model as a migration of API, not a flag flip. The pragmatic route is new platform APIs on v2 scopes, with legacy APIs retired as consuming teams move. Do not edit live Compositions in place; version the XRD and create a new Composition instead.

Trade-offs, Gotchas, and What Goes Wrong

Functions are a new failure domain. Every XR depends on the function pods in its pipeline. If a function Deployment is down or crash-looping, reconciliation for every XR that uses it stalls, and a bad function release can break all of them at once. Run multiple replicas, pin versions, canary new function versions against a staging control plane, and alert on function pod health.

Omitting a resource deletes it. The copy-forward rule is the sharpest edge in the model. A conditional that stops emitting a resource because an input changed, or a template bug that renders an empty document, will cause Crossplane to delete the corresponding cloud object. Protect stateful resources with Usages or deletion policies (deletionPolicy: Orphan on managed resources where appropriate), and require review on Composition changes as you would for database migrations.

Renames are destructive. Changing a composed resource’s stable name looks like delete-and-create. Choose names once and treat them as API.

Provider sprawl costs memory. Each provider is a controller with a large set of CRDs. Managed resource activation policies (MRAPs, introduced with MRDs in v2) let you activate only the kinds you use, and v2.4’s safe-start provider runtimes scale providers to zero until needed. Ignoring this is a common reason for a heavy control plane.

Secret handling is easy to get wrong. Removing XR connection details forced explicit secrets. Do not pass credentials through function context or logs. Rely on provider-generated secrets, external secret managers, and workload identity.

The Terraform-style plan is missing. Reviewers used to plan output need a substitute: rendered manifests in pull requests, crossplane resource validate in CI, and staged rollouts. Without these, teams discover changes only after they apply.

SDK instability. The Go and Python function SDKs state they are beta and may break before v1.0.0. Pin them, and budget a small upgrade task each minor release. Similarly, Operations, the Pipeline Inspector, and the response cache are alpha.

Debugging is layered. A stuck database might be a function error, a provider error, a cloud quota, or a RBAC gap. Standardize on a runbook: XR conditions first, then crossplane beta trace, then function logs, then provider logs, then cloud audit logs.

Vendor gravity. The open-source project is vendor-neutral, but Upbound sells a distribution (UXP) and paid official providers, while community builds of the same providers are published under crossplane-contrib. Decide deliberately which path you take, and avoid depending on a registry you do not control without mirroring packages internally.

Not every workload deserves this. If you have three teams and fifteen databases, a well-run Terraform module with a Backstage template may deliver value sooner. Crossplane pays back when tenant count and change frequency make continuous, API-driven provisioning worth an operated control plane.

Practical Recommendations

Start narrow. Pick one high-demand resource, usually a database or a storage bucket, and ship it as a v2 namespaced XR with a schema of five fields or fewer. Prove the developer workflow, the incident workflow, and the upgrade workflow before adding a second API. A platform with three excellent APIs beats one with thirty half-tested ones.

Treat function and provider versions like production dependencies. Mirror packages to an internal registry, pin digests, and promote through environments. Test with crossplane composition render and crossplane resource validate in CI, keep a small cluster smoke test, and require that every Composition change ships with new render fixtures.

Put guardrails where they are cheapest. Encode constraints in the XRD schema and CEL first, organization-wide rules in admission policy, and stateful logic in functions. Keep functions small, single-purpose, and stateless.

Plan for people, not just tooling. Someone must own the function codebase, the release notes for each Crossplane minor, and the on-call rotation for the control plane.

A rollout checklist:

  • Confirm you are on a supported Crossplane minor and have a quarterly upgrade slot.
  • Use scope: Namespaced XRDs for new APIs; keep legacy scopes only for existing consumers.
  • Ship every Composition as mode: Pipeline with pinned Function packages and fully qualified names.
  • Run at least two replicas of each function and set resource requests through DeploymentRuntimeConfig.
  • Protect stateful resources with deletion policies or Usages, and review any change that can remove composed resources.
  • Add render fixtures, schema validation, and a smoke test to CI.
  • Activate only the managed resource kinds you need with MRAPs.
  • Keep secrets out of function context; use provider secrets or External Secrets Operator.
  • Give GitOps health checks for XR Ready and Synced conditions.
  • Pilot Operations only on non-critical automation while they remain alpha.

Frequently Asked Questions

What are Crossplane composition functions?

Composition functions are gRPC services that a Crossplane Composition calls in sequence to compute the desired set of resources for a composite resource. Each function receives observed state, the desired state accumulated so far, its own input, and pipeline context, and returns updated desired state. You can write them with Go or Python SDKs, KCL, or templates. Since Crossplane v2, every Composition is a function pipeline because native patch and transform mode was removed.

Does Crossplane v2 still use claims?

Not for the new scopes. In v2, composite resources are namespaced by default, so developers create the XR directly in their namespace and Kubernetes RBAC governs access. Claims remain only for legacy cluster-scoped XRDs, which use scope: LegacyCluster to keep v1 behavior working during migration. There is no automatic conversion of existing cluster-scoped XRs to namespaced ones, so plan a deliberate API migration, ideally introducing new APIs on the v2 model and retiring old ones over time.

Is Crossplane better than Terraform for an internal developer platform?

It depends on the job. Crossplane offers continuous reconciliation, Kubernetes-native RBAC, and a self-service API that portals and GitOps tools consume directly, which suits tenant-facing platform resources. Terraform and OpenTofu provide first-class plans, a huge module ecosystem, and simpler operations for foundational infrastructure. Many teams use Terraform or OpenTofu for landing zones and the management cluster, then Crossplane for developer self-service on top.

How do I test a composition function locally?

Use crossplane composition render with an XR, a Composition, and a functions file. It runs the real function containers through Docker and prints the resources that would be created, with no cluster required. Add --observed-resources to simulate existing resources, --required-resources for extra inputs, and the development runtime to attach a debugger to a function on localhost. Combine it with crossplane resource validate for schema checks and golden-file comparisons in CI.

Is Crossplane production ready, and who maintains it?

Crossplane graduated in the CNCF on November 6, 2025, the foundation’s highest maturity level, after completing two security audits and reaching more than 70 public adopters. Releases arrive quarterly and each is maintained for about nine months. Upbound, a major contributor, sells UXP, a distribution built on open-source Crossplane. Some newer features, including Operations, the Pipeline Inspector, and the response cache, remain alpha, so check each feature’s maturity before depending on it.

What are Crossplane Operations and should I use them?

Operations are v2 resources that run a function pipeline to completion, on a cron schedule, or in response to a watched resource change. They target tasks such as backups, certificate rotation, and staged upgrades that do not fit continuous reconciliation. They are alpha, need the --enable-operations flag, and may change or be removed, so pilot them on non-critical automation and keep the underlying logic portable until they mature.

Further Reading

Related posts on this site:

External sources:

By Riju — about

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *