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-vpc

ArgoCD 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: 5m

This 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.