You Don’t Have to Pick One (But You Should Know the Difference)
Here’s a scenario that probably sounds familiar: you’ve got a k3s cluster humming along on a couple of Proxmox VMs, ArgoCD is happily syncing your Helm charts, and then you think — “hey, I should manage my Cloudflare DNS records the same way.” Or maybe you’re provisioning Hetzner servers and want to stop running terraform apply in a terminal at midnight.
So now you’re staring at Crossplane and wondering if it’s worth the YAML.
Honestly? Both tools are solid. But they solve the same problem from completely different angles, and picking the wrong one for your use case will make your life miserable in very specific, hard-to-Google ways. Let’s break it down.
Terraform (and OpenTofu): The Workhorse You Already Know
Terraform has been the default infrastructure-as-code tool for so long it’s practically furniture. You write HCL, you run plan, you squint at the diff, you run apply, and your cloud resources appear. Simple enough that you can explain it to a coworker in two minutes.
The state file is the heart of how it works. Terraform tracks what it thinks exists in a terraform.tfstate JSON blob — local by default, remote (S3, GCS, Terraform Cloud) for anything you care about. Every plan compares desired state (your .tf files) against that cached state, then compares against live reality. That three-way diff is where drift gets caught.
terraform { required_providers { cloudflare = { source = "cloudflare/cloudflare" version = "~> 4.0" } } backend "s3" { bucket = "my-tf-state" key = "homelab/cloudflare/terraform.tfstate" region = "auto" endpoint = "https://your-account.r2.cloudflarestorage.com" skip_credentials_validation = true skip_metadata_api_check = true skip_region_validation = true force_path_style = true }}
resource "cloudflare_record" "homepage" { zone_id = var.zone_id name = "@" value = "100.x.x.x" type = "A" ttl = 1 proxied = true}The provider ecosystem is enormous. AWS, GCP, Azure obviously — but also Cloudflare (fully mature, battle-tested), Hetzner Cloud, Tailscale, Vault, GitHub, Grafana, even Proxmox. If a SaaS product has an API, there’s probably a Terraform provider for it, or someone’s building one.
The BSL Drama and OpenTofu
In 2023, HashiCorp switched Terraform from MPL to the Business Source License — which is not open source. The community forked it into OpenTofu, which is now stewarded by the Linux Foundation. If you’re starting fresh, OpenTofu is the obvious choice for self-hosted setups. It’s a drop-in replacement: same HCL, same providers, same CLI workflow.
# OpenTofu is a drop-in replacementtofu inittofu plantofu applyWhere Terraform Falls Short
Drift detection requires you to actually run a plan. Nothing’s watching your Cloudflare zone at 3 AM when someone (you, after two beers) clicks around in the dashboard and creates a duplicate DNS record. You have to schedule plans — either via CI (GitHub Actions, GitLab), Atlantis, or Spacelift — to catch drift before it bites you.
Secrets are also a weak point. Sensitive outputs are marked with sensitive = true but they still exist in your state file as plaintext. If your state backend isn’t locked down properly, you’ve got credentials sitting in JSON on an S3 bucket. Not ideal.
Crossplane: Infrastructure as Kubernetes Resources
Crossplane takes a completely different approach: it runs inside your cluster as a set of controllers, and infrastructure resources become Kubernetes Custom Resource Definitions. Want a DNS record? Apply a YAML manifest. The Crossplane controller picks it up, calls the Cloudflare API, and then reconciles that resource every few minutes forever.
This is the core mental shift. With Terraform, you run a command. With Crossplane, you declare state and controllers continuously work toward it.
Installing Crossplane on k3s
helm repo add crossplane-stable https://charts.crossplane.io/stablehelm repo update
helm install crossplane crossplane-stable/crossplane \ --namespace crossplane-system \ --create-namespace \ --set args='{"--enable-usages"}'Then you install providers. Upbound maintains official providers for AWS, Azure, and GCP. Community providers cover Cloudflare, Hetzner, Tailscale, and others.
# Install the Cloudflare community providerkubectl apply -f - <<EOFapiVersion: pkg.crossplane.io/v1kind: Providermetadata: name: provider-cloudflarespec: package: xpkg.upbound.io/crossplane-contrib/provider-cloudflare:v0.5.0EOFProviders run as pods in your cluster. Watch them come up:
kubectl get providers# NAME INSTALLED HEALTHY PACKAGE AGE# provider-cloudflare True True xpkg.upbound.io/crossplane-contrib/provider-cloudflare 2mCreating a DNS Record as a CRD
Once your provider is healthy, configure credentials via a ProviderConfig and then create resources:
apiVersion: v1kind: Secretmetadata: name: cloudflare-credentials namespace: crossplane-systemtype: OpaquestringData: credentials: | { "api_token": "your-cloudflare-api-token" }---apiVersion: cloudflare.crossplane.io/v1alpha1kind: ProviderConfigmetadata: name: defaultspec: credentials: source: Secret secretRef: namespace: crossplane-system name: cloudflare-credentials key: credentials---apiVersion: dns.cloudflare.crossplane.io/v1alpha1kind: Recordmetadata: name: homepage-dnsspec: forProvider: zoneId: "your-zone-id" name: "@" type: "A" content: "100.x.x.x" proxied: true ttl: 1 providerConfigRef: name: defaultApply it with kubectl apply -f dns-record.yaml and your ArgoCD picks it up automatically. The Cloudflare provider controller reconciles every ~30 seconds by default. Drift detection is continuous — if someone deletes that record in the Cloudflare dashboard, the controller creates it again within the minute.
That’s the killer feature. Not just “it’s GitOps” but that it’s always reconciling. No cron job, no Atlantis webhook, no scheduled pipeline.
Compositions: The Weird Part
Crossplane has a concept called Compositions and Composite Resources (XRs). This is where it gets powerful and also where it gets strange.
The idea: define a high-level abstraction like “Database” that expands into multiple provider resources. Your platform team defines the Composition, and developers apply simple XR manifests without knowing the underlying cloud resources.
apiVersion: apiextensions.crossplane.io/v1kind: CompositeResourceDefinitionmetadata: name: xpostgresdatabases.homelab.sumguy.comspec: group: homelab.sumguy.com names: kind: XPostgresDatabase plural: xpostgresdatabases claimNames: kind: PostgresDatabase plural: postgresdatabases versions: - name: v1alpha1 served: true referenceable: true schema: openAPIV3Schema: type: object properties: spec: type: object properties: parameters: type: object properties: storageGB: type: integer region: type: stringThen a Composition wires that XR to real provider resources — an RDS instance, a security group, subnet groups. Developers just kubectl apply a PostgresDatabase manifest with storageGB: 20 and the whole stack appears.
For a home lab, this is probably overkill unless you’re building a shared cluster for multiple projects or practicing for work. But it’s genuinely elegant once you grok it.
Where Crossplane Falls Short
Provider maturity is uneven. Upbound’s AWS, Azure, and GCP providers are production-grade. Community providers for Cloudflare, Hetzner, and Tailscale are functional but sometimes lag on newer API features. If your provider doesn’t support a resource you need, you’re either writing Go or waiting.
The YAML volume is real. A simple DNS record in Terraform is 8 lines of HCL. In Crossplane it’s a ProviderConfig, a Secret, and then the resource manifest — easily 40+ lines before you’ve done anything interesting.
Debugging provider failures means kubectl describe on your managed resource and reading Kubernetes events. Not terrible, but different from what Terraform users expect.
Side-by-Side: What Actually Matters
| Terraform / OpenTofu | Crossplane | |
|---|---|---|
| State storage | External file (local, S3, R2) | In-cluster etcd |
| Drift detection | On demand (plan) | Continuous |
| GitOps integration | Needs Atlantis / CI | Native (ArgoCD/Flux) |
| Provider coverage | Massive, mature | Good on major clouds, growing elsewhere |
| Secrets handling | In state file (plaintext) | Written to K8s Secrets |
| Learning curve | HCL — approachable | YAML + Compositions — steep |
| Modules/abstractions | Terraform modules | Compositions + XRs |
| Best for | Provisioning, one-shot infra | Continuous reconciliation, K8s-native |
GitOps Integration Is Where This Gets Real
If you’re running ArgoCD or Flux, Crossplane is genuinely first-class. Your Cloudflare DNS records, Hetzner servers, and Tailscale ACLs all live in Git as YAML, ArgoCD syncs them like any other manifest, and the controllers handle the actual API calls. The entire flow is Kubernetes-native — no extra tooling, no webhook pipelines.
With Terraform in a GitOps setup, you need something that runs tofu apply on git push. Atlantis is the self-hosted option — it watches your PRs, runs plans, posts comments. It works well but it’s another thing to run, another service to keep healthy, another set of webhooks to maintain.
Atlantis config in your repo:
version: 3projects: - name: cloudflare-dns dir: infra/cloudflare workspace: default autoplan: enabled: true when_modified: ["*.tf", "*.tfvars"]Not complicated, but compare that to “ArgoCD already watches everything” and you’ll see why Crossplane is appealing for k3s users.
Real Home Lab Scenarios
Scenario A: You want your Cloudflare DNS records in Git and reconciled automatically.
Use Crossplane. Install provider-cloudflare, commit your Record manifests to your GitOps repo, let ArgoCD sync them. If you fat-finger something in the Cloudflare dashboard, it gets fixed within a minute. This is where Crossplane genuinely shines.
Scenario B: You’re spinning up a Hetzner Cloud VPS once a month to run a project, then tearing it down.
Use OpenTofu. It’s a one-shot provisioning operation, you don’t need continuous reconciliation, and Hetzner’s Terraform provider is more complete than the Crossplane equivalent. Run it locally or from a small CI job.
Scenario C: You want to manage AWS S3 buckets, IAM roles, and RDS instances from your home lab cluster.
Crossplane’s Upbound AWS provider is production-grade here. If you’re already running Crossplane for other things, adding AWS resources as CRDs makes sense. If you’re not already on Crossplane, Terraform is the lower-friction entry point.
Scenario D: You want to define a “self-hosted app” abstraction that creates a Hetzner server, DNS record, and Cloudflare tunnel in one shot.
Crossplane Compositions. This is exactly what they’re for — and there’s no clean equivalent in Terraform modules when you want continuous reconciliation of the result.
The Hybrid Approach (It’s Fine, Stop Stressing)
Here’s the honest take: you don’t have to commit to one tool for everything. Plenty of home labs run Crossplane for resources that need continuous reconciliation (DNS, K8s-native infra, secrets rotation) and OpenTofu for one-shot provisioning (VPS creation, storage buckets, firewall rules you set and forget).
The tools don’t conflict. Your Crossplane resources live in your GitOps repo. Your OpenTofu state lives in R2 or Minio. They manage different resources, they don’t step on each other.
The “you must pick a single tool and be pure about it” mentality is the same energy as “everything must be in Kubernetes” — technically coherent, practically exhausting.
Should You Bother With Crossplane?
If you’re already running k3s with ArgoCD or Flux: yes, absolutely worth exploring. Install it, add the Cloudflare provider, move your DNS records into GitOps. The continuous reconciliation alone is worth the setup time.
If you’re not running Kubernetes at all and you’re considering adding k3s just to use Crossplane: no. That’s bringing in a cluster to run controllers that call the same APIs Terraform calls from your laptop. OpenTofu + an S3 backend + a weekly CI job for drift detection will serve you just as well with a fraction of the complexity.
If you’re somewhere in between — k3s running, thinking about GitOps, not sure yet — start with Terraform/OpenTofu to get your infra codified, then evaluate Crossplane when continuous reconciliation actually becomes a pain point. You’ll know when you need it.
The bottom line: Crossplane is K8s-native and reconciles continuously. Terraform/OpenTofu has a bigger provider ecosystem and lower setup friction. Neither one is the one true tool. Pick based on what you’re already running, not what sounds cooler in a blog post.