The decision between Crossplane and Terraform rarely comes down to which one can create an RDS instance, because both can, from declarative configuration. It comes down to how you want provisioning to run: as a control plane that lives inside a Kubernetes cluster and reconciles your infrastructure on a loop forever, or as a CLI you invoke to compute a diff and apply it once.
That single architectural choice drives everything downstream: the learning curve, how much you operate day to day, how drift is handled, and who on your team ends up owning the tool. This guide walks through the difference with real configuration for both, then gets specific about which teams each one fits.
Terraform is a binary you run. You write HCL, run terraform plan to see the diff between your configuration and a state file, and run terraform apply to make the cloud match. When the command exits, nothing is running. Terraform has no opinion about what happens to your infrastructure between applies. If someone changes a security group by hand at 2am, Terraform does not know until the next time you run plan.
Crossplane is the opposite shape. You install it into a Kubernetes cluster, where it runs as a set of controllers. You express infrastructure as Kubernetes resources, and Crossplane's controllers watch those resources and continuously reconcile the real cloud state toward them. If someone deletes that RDS instance out of band, Crossplane notices within its reconciliation interval and recreates it. There is no "apply" step you invoke. The control plane is always running and always correcting.
This is the same pattern Kubernetes uses for Pods and Deployments, extended to cloud infrastructure. If you already think in terms of desired state and controllers reconciling toward it, Crossplane will feel natural. If you have never operated Kubernetes, you are signing up to run and maintain a Kubernetes cluster before you provision your first bucket.
| Crossplane | Terraform | |
|---|---|---|
| Execution model | Always-on controllers inside Kubernetes | CLI invoked on demand |
| State | Actual cloud state, tracked as Kubernetes resource status | Explicit state file (local or remote backend) |
| Drift handling | Continuous reconciliation, auto-corrects | Detected only on plan, corrected on apply |
| Config language | YAML (Kubernetes manifests, Compositions) | HCL |
| Runtime dependency | Requires a running Kubernetes cluster | None; runs anywhere |
| Abstraction mechanism | Compositions + Composite Resource Definitions | Modules |
| Self-service model | App teams create Claims via the Kubernetes API | Teams run Terraform or use a wrapper (Atlantis, TFC, Spacelift) |
| Ecosystem maturity | Growing; strong in Kubernetes-native shops | Very large; the de facto IaC standard |
| Primary audience | Platform teams building internal platforms | Anyone provisioning cloud infrastructure |
Take a common case: a managed Postgres database on AWS.
resource "aws_db_instance" "orders" {
identifier = "orders"
engine = "postgres"
engine_version = "16"
instance_class = "db.t3.medium"
allocated_storage = 20
db_name = "orders"
username = "app"
password = var.db_password
skip_final_snapshot = true
vpc_security_group_ids = [aws_security_group.db.id]
}
You run terraform apply, the instance is created, and the CLI exits. Terraform records the resource in its state file. To change the instance class later, you edit the HCL, run plan, then apply. To detect that someone resized it manually, you have to run plan and read the diff.
With Crossplane you first install a provider (here the AWS RDS provider), then declare the database as a Kubernetes resource:
apiVersion: rds.aws.upbound.io/v1beta1
kind: Instance
metadata:
name: orders
spec:
forProvider:
region: us-east-1
engine: postgres
engineVersion: "16"
instanceClass: db.t3.medium
allocatedStorage: 20
dbName: orders
username: app
passwordSecretRef:
namespace: crossplane-system
name: orders-db-password
key: password
skipFinalSnapshot: true
writeConnectionSecretToRef:
namespace: crossplane-system
name: orders-conn
You kubectl apply this manifest. From that point the Crossplane RDS controller owns the instance. If the real instance drifts from spec.forProvider, the controller reconciles it back. The connection details land in a Kubernetes Secret (orders-conn) that your app can mount directly, which is a nice property when your workloads already run in the same cluster.
At this raw level the two are close in verbosity. The divergence shows up when you build abstractions.
Terraform's abstraction unit is the module. You wrap resources in a module, expose input variables, and consumers call it.
module "postgres" {
source = "./modules/postgres"
name = "orders"
instance_size = "medium"
}
The consumer still runs Terraform. They need the Terraform toolchain, credentials, and a way to run apply safely (which in practice means Atlantis, Terraform Cloud, Spacelift, or a CI pipeline).
Crossplane's abstraction unit is a Composition paired with a Composite Resource Definition (XRD). The XRD defines a new Kubernetes API, and the Composition defines how a claim of that API maps to real cloud resources. A platform team authors this once:
apiVersion: apiextensions.crossplane.io/v1
kind: CompositeResourceDefinition
metadata:
name: xpostgresinstances.platform.acme.io
spec:
group: platform.acme.io
names:
kind: XPostgresInstance
plural: xpostgresinstances
claimNames:
kind: PostgresInstance
plural: postgresinstances
versions:
- name: v1alpha1
served: true
referenceable: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
size:
type: string
enum: ["small", "medium", "large"]
Then an application team, without touching AWS APIs or knowing what an RDS instance is, requests a database through a Claim:
apiVersion: platform.acme.io/v1alpha1
kind: PostgresInstance
metadata:
name: orders
spec:
size: medium
writeConnectionSecretToRef:
name: orders-conn
This is the case where Crossplane earns its complexity. The app team interacts with a Kubernetes API your platform team designed. They cannot see or misuse the underlying cloud primitives. Everything they request flows through the same admission controllers, RBAC, and GitOps pipeline as the rest of your Kubernetes resources. If you are building an internal developer platform and you already run Kubernetes, this self-service story is hard to replicate cleanly in Terraform without building a lot of wrapper tooling yourself.
The cost is that authoring Compositions is a real skill. Patch-and-transform mappings, EnvironmentConfigs, and the newer composition functions have a learning curve that goes well beyond writing HCL. Teams routinely underestimate it.
One version note for 2026: Crossplane v2 (August 2025) made Composite Resources namespaced and deprecated the separate Claim type in favor of requesting XRs directly. The Claim-based flow shown above still works for backward compatibility, and the self-service tradeoff against Terraform is the same either way, but new Crossplane setups increasingly skip the Claim and use a namespaced XR.
This is where the architectures produce different outcomes rather than just different syntax.
Terraform sees drift only when you ask. Run terraform plan and it refreshes state against the real cloud and shows you what changed. Between runs, drift accumulates silently. Teams paper over this with scheduled plan runs in CI and drift-detection products, but the tool itself is passive by design. See managing Terraform drift for the common mitigations.
Crossplane's controllers re-reconcile on an interval, so drift gets corrected automatically and continuously. A manually deleted resource comes back. A hand-edited setting reverts to the declared value. For most platform teams this is the point, though it has a sharp edge: emergency manual fixes get reverted by the control plane unless you pause reconciliation first. The behavior you want during an incident is sometimes the opposite of continuous reconciliation.
Crossplane is the stronger choice when:
Terraform is the stronger choice when:
terraform plan as a review gate is a workflow many teams rely on.Note that these are not mutually exclusive. A common pattern is running Crossplane's Terraform provider so that Crossplane reconciles resources defined in HCL, giving you the control-plane model on top of your existing Terraform modules. It also adds two tools' worth of failure modes, so weigh it carefully.
Both tools separate infrastructure configuration from application code. Crossplane keeps it in Kubernetes manifests; Terraform keeps it in HCL. Either way, the service that needs a database and the declaration that provisions the database live in different files, often different repositories, maintained by different people.
For teams whose "infrastructure" is mostly application-level concerns (a Postgres database, a Pub/Sub topic, a cron job, an object storage bucket), a third model changes where the declaration lives. Encore is an open-source backend framework where you declare those resources directly in your TypeScript or Go code, and Encore Cloud provisions the real services in your own AWS or GCP account. There is no YAML control plane to operate and no HCL state file to store.
import { SQLDatabase } from "encore.dev/storage/sqldb";
// Provisions managed Postgres (RDS on AWS, Cloud SQL on GCP, Docker locally).
const db = new SQLDatabase("orders", { migrations: "./migrations" });
The relevant contrast with Crossplane is operational. Crossplane gives you continuous provisioning, but you pay for it by running and maintaining a Kubernetes control plane yourself. Encore gives you provisioning derived from your application code into your own cloud account without operating a control plane at all. The open-source tooling parses the code to determine what to provision. The tradeoff is scope: Encore targets application-layer infrastructure, so if your job is fleet-wide networking, IAM at scale, or infrastructure with no application attached, a general provisioning tool remains the right call. Encore is open source with roughly 12,000 GitHub stars and is used in production by teams including Groupon.
One consequence of declaring infrastructure in application code shows up when an AI agent writes or maintains that code. With Crossplane or Terraform, the resource lives in YAML or HCL that sits apart from the service using it, so an agent has to correlate two files to reason about what a change touches. With Encore, the database, topic, or cron job is a typed value in the same file as the code that uses it. Encore also ships an MCP server and editor rules (encore llm-rules init) that give agents schema, trace, and architecture context, and its conventions push agent output toward a consistent, deployable shape. If AI-assisted development is part of how your backend gets built, see TypeScript for AI development.
If your workload fits Encore's primitives, the TypeScript quick start walks through provisioning a backend, or deploy a working service now:
Want to jump straight to a running app? Clone this starter and deploy it to your own cloud.
Crossplane and Terraform both provision cloud resources, but they are built for different jobs. Crossplane is a Kubernetes-native control plane for platform teams who want continuous reconciliation and self-service infrastructure APIs, and who already have the Kubernetes muscle to run it. Terraform is the general-purpose provisioning CLI with the largest ecosystem, no runtime dependency, and a plan-then-apply workflow that stays out of your way between runs. If you have to ask whether you have the Kubernetes maturity for Crossplane, the answer is usually Terraform.
Crossplane runs as a set of controllers inside Kubernetes and continuously reconciles your cloud infrastructure toward a desired state expressed as Kubernetes resources. Terraform is a CLI that computes a diff against a state file and applies it once when you run it. Crossplane is always-on; Terraform runs when invoked.
Yes. Crossplane is a set of Kubernetes controllers and Custom Resource Definitions, so it requires a running Kubernetes cluster to host the control plane, even if the infrastructure it manages is not Kubernetes-related. Terraform has no such dependency and runs from any machine or CI runner.
No. They overlap on provisioning cloud resources, but Crossplane targets platform teams building self-service abstractions on Kubernetes, while Terraform is a general-purpose provisioning CLI. Many teams run both, sometimes wrapping Terraform inside Crossplane via the Terraform provider.
Crossplane detects and corrects drift continuously because its controllers re-check actual state against desired state on a loop. Terraform only detects drift when you run terraform plan, so out-of-band changes can sit unnoticed until the next apply.
Choose Crossplane when you already operate Kubernetes, want a self-service platform where app teams request infrastructure through Kubernetes APIs, and want continuous reconciliation. Choose Terraform for straightforward provisioning without running a control plane.