Managing infrastructure the same way as applications: Terraform with GitOps, Crossplane as native Kubernetes IaC, Cluster API for cluster lifecycle, and full-stack GitOps patterns from infrastructure, platform, to applications.

In episode 28 we arranged for many tenants to live peacefully in one platform. But there's a big gap we haven't closed: how is the infrastructure itself created? So far we've assumed clusters already exist — yet in the real world, clusters, VPCs, databases, and S3 buckets also have to be managed, changed, and tracked. If applications are GitOps-managed but the infrastructure is done manually through a cloud console, then we're only half-GitOps.
This episode closes that gap. We discuss infrastructure GitOps — applying the same principles (Git as the source of truth, declarative, auto-sync) to the layer below applications: Terraform, Crossplane, and Cluster API. The goal: the entire stack from cluster to application is managed from one Git, one pipeline, one source of truth.
Infrastructure as Code (IaC) is the prerequisite for infrastructure GitOps: if infrastructure can't be expressed as code, it can't be managed from Git. Four main approaches:
| Approach | Model | ArgoCD's role |
|---|---|---|
| Terraform | External IaC, state in a backend | ArgoCD runs the workflow, doesn't apply |
| Crossplane | CRDs inside the cluster, continuous reconcile | ArgoCD applies CRDs + claims directly |
| Cluster API | Kubernetes-native for cluster lifecycle | ArgoCD manages CAPI resources and add-ons |
| External provisioners | Third-party operator/controller | ArgoCD applies operator resources |
The key difference: Terraform is pull-once (applies when run), while Crossplane is reconcile-continuously (like a Kubernetes controller). Crossplane fits the GitOps mental model better because there's no manual "terraform apply" step — resources are continuously adjusted to the desired state.
Terraform doesn't understand continuous reconciliation, so the GitOps pattern for Terraform is usually a workflow pipeline (episode 18). CI detects changes in the infra/ repo, runs terraform plan, waits for approval, then terraform apply:
jobs:
terraform:
steps:
- run: terraform init -backend-config=backend.tfvars
- run: terraform plan -out=tfplan
- run: terraform apply tfplanBecause Terraform state is stored outside Git (e.g. S3), Git isn't the only source of truth here — this structural weakness is why many teams switch to Crossplane for things that can be continuously reconciled. Terraform remains ideal for resources without a Kubernetes-native controller: VPCs, IAM, certain DNS providers.
Crossplane places a provider inside the cluster that can create any cloud resource — S3 bucket, RDS, GKE cluster — as a Kubernetes CRD. This turns the cloud provider into "an API managed by a controller".
A provider is a controller that talks to a cloud API:
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
name: provider-aws-s3
spec:
package: xpkg.upbound.io/upbound/provider-aws-s3:v1.30.0
---
apiVersion: aws.upbound.io/v1beta1
kind: ProviderConfig
metadata:
name: default
spec:
credentials:
source: Secret
secretRef:
name: aws-creds
namespace: crossplane-systemCrossplane separates abstraction and implementation through two concepts:
PostgresDB, realized as a combination of an RDS instance, security group, and credentials.apiVersion: platform.example.org/v1alpha1
kind: PostgresDB
metadata:
name: billing-db
namespace: tenant-billing
spec:
size: smallThe tenant just creates a claim; Crossplane realizes it. The claim status can be monitored directly: kubectl get postgresdb billing-db -n tenant-billing shows whether the claim is READY. This is the "self-service" analog for infrastructure — a perfect pair with the tenant onboarding pattern from episode 28.
Because Crossplane resources are ordinary CRDs, ArgoCD manages them without a special plugin. An Application can target claims, providers, and compositions directly:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: platform-database
namespace: argocd
spec:
project: platform
source:
repoURL: https://github.com/org/infra
path: crossplane/claims
destination:
server: https://kubernetes.default.svc
namespace: tenant-billingTip
Separate the layer managing Crossplane itself from the claim layer. The platform team manages Provider, ProviderConfig, and Composition (as an Application with the platform project); tenants manage only their claims. That way, tenants can't change platform behavior.
Cluster API (CAPI) brings the Kubernetes pattern to the cluster lifecycle itself: Cluster and MachineDeployment are CRDs, and the controller brings clusters up and down as desired.
The cluster lifecycle is managed through CAPI resources: Cluster for the control plane, MachineDeployment for the node pool, MachineHealthCheck to replace broken nodes. A cluster upgrade = changing the version in MachineDeployment and letting the controller do the rolling update — all declarable in Git. To see the condition of the whole fleet: kubectl get clusters -A.
After a cluster is born, fill it with add-ons. A common pattern: ArgoCD manages an ApplicationSet with a cluster generator (episode 11) that deploys add-ons to all registered clusters:
spec:
generators:
- clusters: {}
template:
metadata:
name: 'addon-metrics-{{name}}'
spec:
project: platform
source:
repoURL: https://github.com/org/addons
path: metrics-server
destination:
server: '{{server}}'The CAPI + ArgoCD combination produces a self-healing fleet: CAPI keeps the cluster count as desired, ArgoCD keeps each cluster's contents matching Git. The hub-and-spoke from episode 9 becomes truly automatic — a new cluster appears, gets registered, and gets filled immediately.
The end goal is one pipeline for the entire stack, in three layers:
| Layer | Example | Tools |
|---|---|---|
| Infrastructure | VPC, cluster, database, bucket | Terraform / Crossplane / CAPI |
| Platform | Ingress, monitoring, ArgoCD, policies | ArgoCD + ApplicationSet |
| Application | Business services | ArgoCD + App of Apps |
Cross-layer coordination is done through order and dependencies — not manual waiting. An ApplicationSet for the platform waits until the cluster is healthy (detected by CAPI), then the platform add-ons are deployed; applications wait for the platform to be ready. Layer dependencies are expressed with sync waves (episode 7) or pipelines that only trigger the next layer if the previous one is Healthy.
Warning
Be careful with the bootstrap cycle. ArgoCD managing ArgoCD (self-managed) is a valid pattern (episode 6), but it needs an emergency manual path: if ArgoCD is lost, you must be able to bootstrap it again from Git without ArgoCD. Store the bootstrap documentation and scripts in a repo, and test them in a DR drill (episode 21).
This episode extended GitOps downward: Terraform with the workflow pattern for non-Kubernetes resources, Crossplane as native Kubernetes IaC with providers, composite resources, and claim-based provisioning, Cluster API for lifecycle, upgrades, and cross-cluster add-on management, and full-stack GitOps patterns with three coordinated layers.
The points you should take with you:
Infrastructure doesn't run alone; it lives in a broader CI/CD tool ecosystem. In the next episode 30 we discuss ecosystem integration — Tekton, Jenkins and Jenkins X, GitHub Actions, GitLab CI/CD, FluxCD migration, Terraform integration, policy engines, and Backstage. See you in episode 30!