Skip to content
Go back

Crossplane vs Terraform for Home Lab

By SumGuy 10 min read
Crossplane vs Terraform for Home Lab

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.

Terminal window
# OpenTofu is a drop-in replacement
tofu init
tofu plan
tofu apply

Where 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

Terminal window
helm repo add crossplane-stable https://charts.crossplane.io/stable
helm 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.

Terminal window
# Install the Cloudflare community provider
kubectl apply -f - <<EOF
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
name: provider-cloudflare
spec:
package: xpkg.upbound.io/crossplane-contrib/provider-cloudflare:v0.5.0
EOF

Providers run as pods in your cluster. Watch them come up:

Terminal window
kubectl get providers
# NAME INSTALLED HEALTHY PACKAGE AGE
# provider-cloudflare True True xpkg.upbound.io/crossplane-contrib/provider-cloudflare 2m

Creating a DNS Record as a CRD

Once your provider is healthy, configure credentials via a ProviderConfig and then create resources:

apiVersion: v1
kind: Secret
metadata:
name: cloudflare-credentials
namespace: crossplane-system
type: Opaque
stringData:
credentials: |
{
"api_token": "your-cloudflare-api-token"
}
---
apiVersion: cloudflare.crossplane.io/v1alpha1
kind: ProviderConfig
metadata:
name: default
spec:
credentials:
source: Secret
secretRef:
namespace: crossplane-system
name: cloudflare-credentials
key: credentials
---
apiVersion: dns.cloudflare.crossplane.io/v1alpha1
kind: Record
metadata:
name: homepage-dns
spec:
forProvider:
zoneId: "your-zone-id"
name: "@"
type: "A"
content: "100.x.x.x"
proxied: true
ttl: 1
providerConfigRef:
name: default

Apply 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/v1
kind: CompositeResourceDefinition
metadata:
name: xpostgresdatabases.homelab.sumguy.com
spec:
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: string

Then 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 / OpenTofuCrossplane
State storageExternal file (local, S3, R2)In-cluster etcd
Drift detectionOn demand (plan)Continuous
GitOps integrationNeeds Atlantis / CINative (ArgoCD/Flux)
Provider coverageMassive, matureGood on major clouds, growing elsewhere
Secrets handlingIn state file (plaintext)Written to K8s Secrets
Learning curveHCL — approachableYAML + Compositions — steep
Modules/abstractionsTerraform modulesCompositions + XRs
Best forProvisioning, one-shot infraContinuous 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: 3
projects:
- 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.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Next Post
Jellyseerr Tagging Workflows for Real Libraries

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts