Crossplane and Terraform (Part 2): Building a Hybrid Control Plane Without Rewriting HCL

Learn how to build a hybrid control plane using Crossplane and Terraform without rewriting your existing HCL. Discover migration strategies, architectures, and state management.

Crossplane and Terraform (Part 2): Building a Hybrid Control Plane Without Rewriting HCL

If you've already built thousands of lines of Terraform, migrating everything to Crossplane sounds great in theory.

Until someone asks:

"What happens to all the Terraform we already have?"

You probably don't want to throw away years of infrastructure code just to adopt a Kubernetes-native control plane.

And you don't have to.

Welcome to DevOps Inside, where we go beyond the surface to understand how modern DevOps tools actually work under the hood.

In Part 1, we looked at where Terraform starts becoming difficult at scale, especially around continuous reconciliation, self-service infrastructure, and Kubernetes-native workflows.

This time, we're going one step further.

Instead of choosing between Terraform and Crossplane, what if you used both?

The idea is simple:

Crossplane becomes the control plane. Terraform keeps doing the infrastructure work it already knows how to do.

That gives teams a migration path without forcing a giant HCL rewrite.

The Hybrid Architecture

The basic flow looks like this:

Developer
    |
    | Kubernetes API
    v
+----------------------+
| Crossplane           |
| Composite Resource   |
+----------+-----------+
           |
           | Composition
           v
+----------------------+
| provider-terraform   |
| Workspace            |
+----------+-----------+
           |
           | Terraform module
           v
+----------------------+
| Existing Terraform   |
| modules / HCL        |
+----------+-----------+
           |
           v
      Cloud Provider

The important part is that the existing Terraform modules don't need to disappear.

Crossplane provides the API and reconciliation model around them.

Terraform continues managing the infrastructure underneath.

Why Keep Terraform at All?

If Crossplane can manage cloud resources directly, why introduce Terraform into the architecture?

Because real infrastructure estates are rarely greenfield.

You may already have:

  • Hundreds of Terraform modules
  • Years of HCL
  • CI/CD pipelines built around Terraform
  • Remote state
  • Existing provider configurations
  • Review and approval workflows
  • Teams that already understand Terraform
  • Infrastructure that would be risky to recreate

Rewriting all of that just to change the control plane isn't always worth the operational risk.

A hybrid model lets you move the interface first.

Developers interact with Kubernetes resources.

Platform engineers can gradually decide which infrastructure should remain Terraform-backed and which resources should eventually move to Crossplane-managed resources.

That's a much more realistic migration strategy.

Step 1: Define the Crossplane API

The first step is to create an API that developers actually interact with.

For example, instead of asking a developer to understand an internal Terraform module:

module = "rds"
allocated_storage = 50
environment = "prod"

they could create:

apiVersion: platform.devopsinside.com/v1alpha1
kind: XPostgresDatabase
metadata:
  name: orders-db
spec:
  storageGB: 50
  environment: prod

The developer doesn't need to know whether the infrastructure underneath is implemented using Terraform, a Crossplane provider, or something else.

That's the point of the abstraction.

With current Crossplane versions, a new XRD can be namespaced using apiextensions.crossplane.io/v2. Crossplane's current documentation recommends namespaced XRDs for most use cases.

apiVersion: apiextensions.crossplane.io/v2
kind: CompositeResourceDefinition
metadata:
  name: xpostgresdatabases.platform.devopsinside.com
spec:
  scope: Namespaced

  group: platform.devopsinside.com

  names:
    kind: XPostgresDatabase
    plural: xpostgresdatabases

  versions:
  - name: v1alpha1
    served: true
    referenceable: true

    schema:
      openAPIV3Schema:
        type: object

        properties:
          spec:
            type: object

            properties:
              storageGB:
                type: integer
                default: 20

              environment:
                type: string
                enum:
                - dev
                - staging
                - prod

Now the platform team owns the API.

The implementation underneath can change later without forcing every application team to change how they request infrastructure.

Step 2: Connect That API to Existing Terraform

This is where the hybrid model becomes interesting.

Crossplane's Terraform provider exposes a Workspace managed resource that can execute existing Terraform modules.

The current provider-terraform v1.2.0 package supports a namespaced Workspace under tf.m.upbound.io/v1beta1. The provider supports remote Terraform modules and variables through the Workspace resource.

A simplified Composition can look like this:

apiVersion: apiextensions.crossplane.io/v1
kind: Composition

metadata:
  name: postgres-via-terraform

spec:
  compositeTypeRef:
    apiVersion: platform.devopsinside.com/v1alpha1
    kind: XPostgresDatabase

  mode: Pipeline

  pipeline:
  - step: patch-and-transform

    functionRef:
      name: function-patch-and-transform

    input:
      apiVersion: pt.fn.crossplane.io/v1beta1
      kind: Resources

      resources:

      - name: tf-rds-module

        base:
          apiVersion: tf.m.upbound.io/v1beta1
          kind: Workspace

          spec:
            forProvider:
              source: Remote

              module: "git::https://github.com/devopsinside/tf-modules.git//rds?ref=v2.1.0"

              vars:
              - key: allocated_storage
                value: ""

              - key: env
                value: ""

            writeConnectionSecretToRef:
              name: postgres-connection

        patches:

        - type: FromCompositeFieldPath
          fromFieldPath: spec.storageGB
          toFieldPath: spec.forProvider.vars[0].value

          transforms:
          - type: convert
            convert:
              toType: string

        - type: FromCompositeFieldPath
          fromFieldPath: spec.environment
          toFieldPath: spec.forProvider.vars[1].value

Current Crossplane uses Composition Functions through Pipeline mode for this style of composition. The older mode: Resources approach is deprecated for new compositions.

Why the Type Conversion Matters

There's a small detail here that is easy to miss.

Our XRD defines:

storageGB:
  type: integer

But Terraform Workspace variables are passed as Terraform variable values.

If the target field expects a string representation, directly copying an integer can create a type mismatch.

That's why the Composition explicitly converts:

transforms:
- type: convert
  convert:
    toType: string

Crossplane's convert transform is specifically designed to cast one data type to another, including integers to strings.

It's a tiny piece of YAML.

But this is exactly the kind of tiny detail that can turn a seemingly correct Composition into a broken one.

What Happens When Someone Changes the Database?

Suppose the developer changes:

spec:
  storageGB: 100

Crossplane sees the change to the Composite Resource.

The Composition Function produces the desired Terraform Workspace configuration.

The Terraform provider reconciles that Workspace and runs Terraform against the configured module.

The developer doesn't need to run:

terraform plan
terraform apply

manually.

The infrastructure workflow is now being driven by the Kubernetes API.

That's the key shift.

Terraform didn't disappear.

The interface around Terraform changed.

What About Drift?

This is where the hybrid model needs an important clarification.

Crossplane provides the desired-state control plane, but provider-terraform does not magically make Terraform real-time.

The current provider-terraform documentation states that existing Workspaces are polled every 10 minutes by default, so the provider can run terraform plan and determine whether resources are out of sync. Changes to the Workspace spec are reconciled immediately, and the polling interval can be configured.

So don't describe this architecture as:

"Terraform drift is detected instantly."

That's not what the provider guarantees.

A more accurate model is:

Kubernetes API change
        |
        v
Crossplane reconciliation
        |
        v
Terraform Workspace
        |
        v
Terraform plan/apply

For external drift:

Someone changes cloud resource manually
        |
        v
Terraform state / actual infrastructure differs
        |
        v
Workspace polling
        |
        v
Terraform plan
        |
        v
Terraform apply if reconciliation requires it

This distinction matters in production.

Crossplane gives you the control-plane model.

The Terraform provider still determines how frequently its Terraform-backed resources are checked for external drift.

Terraform State Still Matters

There's another important detail that teams sometimes overlook when putting Terraform behind another control plane.

Terraform still needs state.

Crossplane does not replace Terraform's state management for a Terraform-backed Workspace.

The current provider documentation explicitly notes that provider-terraform does not persist Terraform state itself. You should use remote state or ensure that the provider's working directory is not lost.

That means your existing Terraform state architecture still matters.

For example:

Crossplane
     |
     v
Terraform Workspace
     |
     +------ Terraform configuration
     |
     +------ Terraform state
                  |
                  v
          Remote backend

If your organization already has a properly configured remote backend, the hybrid model doesn't require you to throw that away.

This is one of the reasons the approach can work well for existing Terraform estates.

Handling Terraform Outputs and Secrets

Terraform modules often produce useful outputs.

For example:

output "endpoint" {
  value = aws_db_instance.postgres.endpoint
}

output "port" {
  value = aws_db_instance.postgres.port
}

The Terraform provider can expose Terraform outputs through the Workspace connection secret, while non-sensitive outputs can also be reflected in the Workspace status.

When composing this through Crossplane, connection details should be explicitly configured.

For example:

writeConnectionSecretToRef:
  name: postgres-connection

and the composed resource can specify which connection-secret keys should be exposed:

connectionDetails:
- name: endpoint
  type: FromConnectionSecretKey
  fromConnectionSecretKey: endpoint

- name: port
  type: FromConnectionSecretKey
  fromConnectionSecretKey: port

With function-patch-and-transform, Crossplane can automatically aggregate connection details from composed resources into the composite resource's connection secret. The composed resources still need their writeConnectionSecretToRef and connectionDetails configured appropriately.

That gives you a clean flow:

Terraform output
       |
       v
Workspace connection Secret
       |
       v
Crossplane connection details
       |
       v
Application / platform consumer

You can then integrate that secret with your existing secret-management approach, such as External Secrets or Vault.

Hybrid Model vs Pure Terraform vs Native Crossplane

Capability Pure Terraform Hybrid Crossplane + Terraform Native Crossplane
Existing HCL reuse Excellent Excellent Requires migration
Kubernetes-native API Limited Yes Yes
Existing Terraform modules Yes Yes No
Continuous Crossplane reconciliation No Yes for Crossplane layer Yes
Terraform state Yes Yes No Terraform state
Kubernetes GitOps workflow Possible Strong fit Strong fit
Gradual migration Possible Excellent Requires migration effort
Infrastructure abstraction Modules XRs + modules XRs + providers

The hybrid model isn't necessarily the final destination.

It can be the bridge.

A Practical Migration Strategy

The biggest mistake would be trying to migrate everything at once.

A safer approach looks more like this.

Stage 1: Build on the Terraform estate you already have

Keep your existing:

  • Terraform modules
  • Remote state
  • Provider configurations
  • CI/CD where necessary
  • Infrastructure definitions

Don't rewrite working infrastructure just because a new control plane has appeared.

Stage 2: Put selected workloads behind Crossplane

Start with infrastructure where a Kubernetes-native API provides obvious value.

For example:

Application
    |
    v
XPostgresDatabase
    |
    v
Crossplane Composition
    |
    v
Terraform Workspace
    |
    v
Existing RDS module

The Terraform module remains intact.

Stage 3: Move the developer experience to Kubernetes

Developers now request infrastructure using Kubernetes resources.

They don't need direct access to Terraform internals.

This is where the platform team starts gaining a consistent self-service interface.

Stage 4: Gradually replace Terraform-backed resources

Some infrastructure may eventually be better managed directly through Crossplane providers.

You can migrate those pieces individually.

For example:

                    +----------------------+
                    | Crossplane XRD       |
                    +----------+-----------+
                               |
                    +----------+----------+
                    |                     |
                    v                     v
             Terraform Workspace   Native Crossplane
                    |                     |
                    v                     v
             Existing module       Cloud provider

The API exposed to developers can remain unchanged.

That's the real benefit.

One Important Caveat in 2026

There is a reason to be deliberate about which Terraform workloads you place behind this model.

The current Upbound provider-terraform v1.2.0 release vendors Terraform CLI 1.5.7 and explicitly states that the provider is frozen at Terraform 1.5.7 rather than adopting Terraform releases under the BSL licensing model.

That doesn't make the provider unusable.

But it is an architectural consideration.

If your existing Terraform estate depends on newer Terraform CLI capabilities, you need to evaluate that compatibility before moving those modules behind provider-terraform.

For newer capabilities, the provider documentation points users toward the OpenTofu-based provider instead.

So the hybrid model should not be treated as:

"Put every Terraform module behind Crossplane."

It's better thought of as:

"Put the right Terraform workloads behind Crossplane while gradually deciding where native Crossplane or another Terraform-compatible engine makes more sense."

What This Architecture Actually Buys You

The interesting part isn't that Crossplane can run Terraform.

The interesting part is that it lets you separate two concerns.

Terraform handles infrastructure implementation. Crossplane handles the platform-facing API and reconciliation model.

That separation gives platform teams room to evolve.

A developer can request:

kind: XPostgresDatabase
spec:
  storageGB: 100
  environment: prod

without knowing whether the implementation is:

Terraform

or:

Crossplane provider

or eventually:

Terraform + Crossplane

The implementation becomes an internal platform concern.

The Bigger Picture

Infrastructure platforms rarely get rebuilt from scratch.

They evolve.

You might start with:

Terraform

Then introduce:

Terraform + GitOps

Then:

Crossplane + Terraform

And eventually:

Crossplane + native providers

There is no requirement that every stage happen at once.

That's what makes the hybrid model useful.

You can keep the infrastructure code that already works while changing the way teams consume infrastructure.

And sometimes changing the interface is more valuable than rewriting the implementation.

Key Takeaways

  • You don't need to rewrite an existing Terraform estate to introduce Crossplane.
  • Crossplane can provide a Kubernetes-native API over existing Terraform modules.
  • provider-terraform exposes Terraform through a Crossplane Workspace.
  • Current namespaced Workspaces use tf.m.upbound.io/v1beta1.
  • Current Crossplane compositions should use Pipeline mode with Composition Functions rather than the deprecated legacy Resources mode.
  • Type conversion matters when Crossplane fields and Terraform variables use different types.
  • Terraform state still needs proper persistence and backend management.
  • Terraform-backed drift detection is not instant by default. The current provider uses a 10-minute polling interval for existing Workspaces, while changes to the Workspace spec are reconciled immediately.
  • Terraform outputs can be exposed through connection secrets and composed into Crossplane connection details.
  • provider-terraform v1.2.0 is based on Terraform 1.5.7, so compatibility with newer Terraform features needs to be evaluated.
  • Hybrid infrastructure is best treated as a migration strategy, not necessarily the final architecture.

Final Thoughts

You don't need to choose between "keep Terraform forever" and "rewrite everything in Crossplane."

There is a middle ground.

Keep the Terraform modules that already work.

Put a Kubernetes-native API in front of them.

Let Crossplane become the control plane while Terraform continues doing the infrastructure work underneath.

Then migrate piece by piece when there's an actual reason to.

That's a much more realistic way to evolve a large infrastructure estate.

Evolution over revolution.

"The best migration strategy is the one that doesn't require you to rewrite everything on day one."

What's Next?

The next step isn't necessarily replacing Terraform.

It's understanding what happens when multiple systems start managing the same infrastructure.

Because once Terraform, Crossplane, GitOps, and cloud controllers enter the same environment, ownership becomes the real problem.

And that's where things get interesting.