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.
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 ProviderThe 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: prodThe 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
- prodNow 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].valueCurrent 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: integerBut 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: stringCrossplane'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: 100Crossplane 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 applymanually.
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/applyFor 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 itThis 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 backendIf 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-connectionand the composed resource can specify which connection-secret keys should be exposed:
connectionDetails:
- name: endpoint
type: FromConnectionSecretKey
fromConnectionSecretKey: endpoint
- name: port
type: FromConnectionSecretKey
fromConnectionSecretKey: portWith 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 consumerYou 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 moduleThe 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 providerThe 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: prodwithout knowing whether the implementation is:
Terraformor:
Crossplane provideror eventually:
Terraform + CrossplaneThe implementation becomes an internal platform concern.
The Bigger Picture
Infrastructure platforms rarely get rebuilt from scratch.
They evolve.
You might start with:
TerraformThen introduce:
Terraform + GitOpsThen:
Crossplane + TerraformAnd eventually:
Crossplane + native providersThere 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-terraformexposes Terraform through a CrossplaneWorkspace.- 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-terraformv1.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.