GitOps and Infrastructure as Code#
GitOps says: the desired state is in Git. A controller continuously reconciles the real state to match. Infrastructure as Code says: the desired state is in code. A human (or agent) runs apply to push changes.
These two paradigms overlap but do not align perfectly. Kubernetes resources fit the GitOps model well — ArgoCD/Flux watch Git, detect differences, and apply changes continuously. Cloud infrastructure (VPCs, databases, IAM roles) fits the IaC model better — Terraform tracks state, computes diffs, and applies on command.
Most real systems need both. This article covers how to combine them without creating reconciliation conflicts, state drift, or operational confusion.
The Reconciliation Gap#
| Property | Terraform (IaC) | ArgoCD/Flux (GitOps) |
|---|---|---|
| State tracking | Explicit state file (S3, Azure Blob, GCS) | Kubernetes API server is the state |
| Reconciliation | On-demand (terraform apply) |
Continuous (every 3 minutes by default) |
| Drift response | Detected on next plan, requires manual fix |
Automatically corrected to match Git |
| Scope | Any cloud resource (AWS, Azure, GCP, K8s, DNS, etc.) | Kubernetes resources (manifests, Helm, Kustomize) |
| Rollback | Git revert + apply (manual) | Git revert → auto-reconciled (automatic) |
| Concurrency | State lock prevents concurrent applies | Multiple sources can conflict |
The gap: Terraform does not continuously reconcile. ArgoCD cannot manage non-Kubernetes resources directly. You need both, and you need clear ownership boundaries.
Pattern 1: Terraform for Cloud, GitOps for Kubernetes#
The most common and safest pattern: use each tool for what it does best.
Terraform manages:
├── VPC / VNET / VPC Network
├── Subnets, NAT, routes
├── EKS / AKS / GKE cluster
├── RDS / Azure SQL / Cloud SQL
├── IAM roles and policies
├── S3 buckets, KMS keys
└── DNS zones
ArgoCD/Flux manages:
├── Kubernetes namespaces
├── Deployments, Services, Ingress
├── Helm releases
├── ConfigMaps, Secrets (sealed/external)
├── CRDs (cert-manager, external-dns)
├── Network Policies
└── RBAC (ClusterRoles, RoleBindings)How They Connect#
Terraform creates the cluster and outputs connection information. ArgoCD/Flux is bootstrapped onto the cluster and takes over Kubernetes resource management:
# Terraform creates the cluster
resource "aws_eks_cluster" "main" {
name = "production"
version = "1.29"
# ...
}
# Terraform bootstraps ArgoCD
resource "helm_release" "argocd" {
name = "argocd"
namespace = "argocd"
repository = "https://argoproj.github.io/argo-helm"
chart = "argo-cd"
version = "6.0.0"
depends_on = [aws_eks_node_group.main]
}After this, Terraform does not manage any Kubernetes resources except the cluster itself. ArgoCD manages everything inside the cluster from Git.
Boundary Rules#
- Terraform owns the cluster lifecycle: creation, version upgrades, node group changes, networking
- ArgoCD owns the cluster content: all resources running inside the cluster
- Neither manages the other’s resources: Terraform does not create Deployments, ArgoCD does not modify the VPC
- Outputs bridge the gap: Terraform outputs (cluster endpoint, database URL, IAM role ARNs) are consumed by ArgoCD apps via ConfigMaps or ExternalSecrets
Pattern 2: Crossplane for Cloud Resources in GitOps#
Crossplane lets you manage cloud resources (RDS, S3, IAM) as Kubernetes CRDs, bringing them into the GitOps reconciliation loop.
# Cloud SQL database managed as a Kubernetes resource
apiVersion: sql.gcp.upbound.io/v1beta1
kind: DatabaseInstance
metadata:
name: production-postgres
spec:
forProvider:
databaseVersion: POSTGRES_15
region: us-central1
settings:
- tier: db-custom-2-8192
diskSize: 50
ipConfiguration:
- ipv4Enabled: false
privateNetwork: projects/myproject/global/networks/production-vpcArgoCD deploys this manifest. Crossplane reconciles it to a real Cloud SQL instance. Drift is automatically corrected.
When Crossplane Makes Sense#
| Scenario | Use Crossplane | Use Terraform |
|---|---|---|
| Databases created per team/namespace | Yes — teams self-serve via K8s manifests | No — too many Terraform PRs for per-team resources |
| Core networking (VPC, subnets, routes) | No — networking is foundational, not per-team | Yes — managed once, rarely changes |
| Per-service S3 buckets | Yes — each service declares its bucket | Maybe — if few services, Terraform is simpler |
| IAM roles for workload identity | Either — Crossplane or Terraform both work | Either |
| EKS/AKS/GKE cluster lifecycle | No — cluster is foundational | Yes — cluster creation is a one-time operation |
| Environment-specific databases | Yes if many environments | Either |
The Hybrid Architecture#
Terraform:
VPC, subnets, NAT, routes
EKS cluster + node groups
IAM foundational roles
S3 state bucket, KMS keys
DNS zone
↓ (cluster exists, Crossplane is installed)
Crossplane + ArgoCD:
Team databases (per-namespace RDS/Cloud SQL)
Team S3 buckets
Per-service IAM roles
Application Kubernetes resources (Deployments, Services, etc.)Terraform handles the platform layer (things that exist once and rarely change). Crossplane handles the application layer (things created per team, per service, per environment).
Pattern 3: Terraform Controller in Kubernetes#
Run Terraform from inside Kubernetes, triggered by ArgoCD:
apiVersion: infra.contrib.fluxcd.io/v1alpha2
kind: Terraform
metadata:
name: networking
namespace: terraform-system
spec:
path: ./infrastructure/networking
sourceRef:
kind: GitRepository
name: infrastructure
approvePlan: auto # or "manual" for human approval
interval: 10m
retryInterval: 5mThis brings Terraform into the GitOps reconciliation loop — changes in Git trigger Terraform plans and applies automatically.
Tradeoffs#
| Advantage | Disadvantage |
|---|---|
| Single reconciliation model (everything is GitOps) | Terraform state management adds complexity inside K8s |
| Drift detected and corrected automatically | auto approve is dangerous for destructive changes |
| No external CI/CD pipeline for infrastructure | Debugging Terraform failures is harder inside a pod |
| Consistent with how applications are deployed | Terraform state lock conflicts if multiple controllers run |
Recommendation: Use this pattern only if your team is already deeply invested in GitOps and wants a single operational model. For most teams, Pattern 1 (Terraform in CI/CD, ArgoCD for K8s) is simpler and safer.
Anti-Patterns#
Dual Management#
The most dangerous anti-pattern: both Terraform and ArgoCD/Crossplane manage the same resource.
Terraform creates aws_db_instance.main → state tracks it
Crossplane creates the same RDS instance via CRD → Crossplane tracks it
Result: Two controllers fight over the same resource.
Terraform sees "unexpected changes" on every plan.
Crossplane sees "drift" every reconciliation cycle.
One overwrites the other's changes in an infinite loop.Fix: Clear ownership. Every resource is managed by exactly one tool. Draw the boundary and document it.
Crossplane for Everything#
Using Crossplane to manage foundational infrastructure (VPC, clusters, IAM) that changes once and needs careful, reviewed changes:
Problem: Crossplane auto-reconciles, including destructive changes.
If the VPC CIDR changes in Git (typo, merge conflict), Crossplane
recreates the VPC — destroying every resource in it.Fix: Use Crossplane for resources where continuous reconciliation is valuable (per-team databases, per-service buckets). Use Terraform with manual approval for foundational resources where a mistake is catastrophic.
Terraform for Kubernetes Application Resources#
Using Terraform to manage Deployments, Services, ConfigMaps:
Problem: Terraform applies once but does not reconcile.
If someone `kubectl edit`s a Deployment, Terraform does not notice
until the next `terraform plan`. ArgoCD would immediately detect
and revert the change.Fix: Use ArgoCD/Flux for resources that benefit from continuous reconciliation. Use Terraform for resources that should only change through reviewed PRs.
Choosing Your Pattern#
| Team Situation | Recommended Pattern |
|---|---|
| Starting fresh, small team | Pattern 1: Terraform for cloud, ArgoCD for K8s |
| Platform team serving many app teams | Pattern 2: Terraform for platform, Crossplane + ArgoCD for team resources |
| Deep GitOps investment, wants single model | Pattern 3: Terraform controller (with caution) |
| Single developer or small project | Terraform only — add GitOps when the team grows |
| Enterprise with change advisory boards | Pattern 1 with strict PR approval gates on Terraform |
The goal is clear ownership, not tool purity. Every resource has exactly one controller. Boundaries are documented. Agents and humans know which tool manages which resource without checking.