Get in Touch
Close

Your Cloud Story,
Engineered for Success

Contacts

US Office: Obsium, 6200,
Stoneridge Mall Rd, Pleasanton CA 94588 USA

Kochi Office: GB4, Ground Floor, Athulya, Infopark Phase 1, Infopark Campus Kakkanad, Kochi 682042

+91 9895941969

hello@obsium.io

Terraform vs Kubernetes

Terraform vs Kubernetes: what each does and how they work together

The confusion is reasonable. Both tools are declarative. Both reconcile actual state against desired state. Both live in the cloud native ecosystem, both get configured in text files checked into Git, and both show up in the same job descriptions.

So people ask which one to use, and that question has no good answer, because it assumes they compete.

Terraform provisions infrastructure. Kubernetes orchestrates containers running on that infrastructure. Most production teams run both, and 82% of container users now run Kubernetes in production (CNCF 2025 Annual Survey), most of them on clusters that Terraform built.

The question worth your time is different: where exactly does the boundary go? Terraform can manage Kubernetes resources. There’s an official provider for it. Whether it should is where teams argue, where architectures go wrong, and where most articles on this topic stop being useful.

We’ve drawn that boundary on a lot of client clusters, and the same failures recur: a Deployment managed in Terraform fighting an autoscaler over replica counts, a node group Terraform keeps trying to shrink back. The reason the two tools fight comes down to a single design difference — and once you see it, the boundary mostly places itself.

For CTOs and anyone newer to the tools

Picture your cloud setup as a building. Terraform is the construction crew: it reads the blueprint, pours the foundation, runs the plumbing and wiring, puts up the structure, then packs up and leaves. Kubernetes is the building manager who takes over once it’s built — assigning offices, replacing anything that breaks, calling for more space when it gets crowded, all day, every day, without being asked.

terraform vs kubernetes

You need both, and they don’t substitute for each other: a construction crew can’t run a building, and a building manager can’t pour a foundation. Nearly every debate in this article reduces to one rule — the crew (Terraform) owns whatever only changes when a person decides to change it; the manager (Kubernetes) owns whatever changes on its own. Trouble starts when the crew keeps coming back to rip out changes the manager made. Keep each in its lane and the building runs quietly.

The short answer

TerraformKubernetes
JobCreate and manage infrastructureRun and manage containers
Operates onCloud APIs (AWS, Azure, GCP, and thousands more)Containers inside a cluster
Answers“Does this VPC/database/cluster exist and match spec?”“Are 5 replicas of this app healthy right now?”
RunsOn demand, in a pipelineContinuously, forever
ConfigHCLYAML

Terraform builds the cluster. Kubernetes runs things inside it. If you’re deciding between them, you’ve likely got a question that needs both.

What is Terraform?

What is Terraform?

Terraform is an infrastructure as code tool from HashiCorp that provisions and manages infrastructure through declarative configuration files.

You describe the infrastructure you want in HCL (HashiCorp Configuration Language). Terraform figures out what needs creating, changing, or destroying to get there, then does it through provider APIs.

Providers and the multi-cloud story

Providers are plugins that translate Terraform configuration into API calls. The public registry lists over 6,900, though only around 430 are official or partner-maintained and the rest vary wildly in quality. The ones you’ll actually use cover AWS, Azure, GCP, and the usual suspects — Cloudflare, Datadog, GitHub, PagerDuty, and Kubernetes itself.

One caveat on “multi-cloud” claims: Terraform gives you one workflow and one language across providers. It does not give you portable configuration. An aws_instance doesn’t become an azurerm_virtual_machine because you changed a provider block. You still write cloud-specific resources; you just write them the same way.

State management, the part that bites people

Terraform keeps a state file mapping your configuration to real-world resources. It’s how Terraform knows that aws_vpc.main in your code corresponds to vpc-0a1b2c3d in your account.

State is the source of most Terraform incidents:

  • Local state means one person’s laptop holds the truth. When they leave, or their disk dies, you’re reconstructing infrastructure by hand.
  • No locking means two engineers running apply simultaneously corrupt state and can destroy resources.
  • Monolithic state means one file for everything, so a change to a DNS record requires refreshing 800 resources and risks the blast radius of your entire estate.
  • Secrets in state are stored in plaintext. Database passwords, private keys, generated credentials all land there.

The fixes are well established:

  • Remote backends (S3 with DynamoDB locking, Terraform Cloud, GCS, Azure Storage) solve the first two.
  • Splitting state by lifecycle and blast radius solves the third.
  • Encryption plus restricted access mitigates the fourth.

The official state documentation is worth reading properly before you’re in an incident.

Warning: If your Terraform state is currently a local file in someone’s home directory, fix that before reading the rest of this article. It’s the highest-severity, lowest-effort infrastructure risk most teams carry.

The workflow

Three steps, and the middle one is the reason people trust Terraform:

  1. Write — describe desired infrastructure in HCL
  2. Planterraform plan shows exactly what will change before anything happens
  3. Applyterraform apply executes the plan

The plan step is Terraform’s real advantage over clicking in a console or running scripts. You see the diff before you commit to it, which makes review possible and makes destructive changes obvious.

Common use cases

  • Provisioning VPCs, subnets, routing, security groups
  • Creating managed databases, caches, queues, object storage
  • Standing up Kubernetes clusters (EKS, AKS, GKE)
  • IAM roles, policies, and service accounts
  • DNS, CDN, certificates, load balancers
  • Multi-environment consistency through modules
  • Anything with a cloud API and a lifecycle measured in weeks or months

What is Kubernetes?

What is Kubernetes?

Kubernetes is a container orchestration platform. You declare what should be running — how many replicas, what image, what resources — and Kubernetes continuously works to make reality match.

The reconciliation loop

This is the concept that matters most for understanding the Terraform boundary, so it’s worth being precise.

Kubernetes runs controllers: processes that watch the current state of a resource and take action to move it toward the declared desired state. That loop never stops (Kubernetes controllers documentation).

Declare 5 replicas, and the Deployment controller ensures 5 exist. Kill a pod, and a new one appears within seconds. Drain a node, and pods reschedule elsewhere. Nobody triggered any of that. The loop is always running.

This is what makes Kubernetes self-healing, and it’s also what makes it fight with tools that assume they’re in sole control.

Cluster architecture

CONTROL PLANE                        WORKER NODES
┌──────────────────────────┐        ┌─────────────────────┐
│  kube-apiserver          │◄──────►│  kubelet            │
│   (front door, all comms)│        │   (runs containers) │
│                          │        │                     │
│  etcd                    │        │  kube-proxy         │
│   (cluster state store)  │        │   (networking)      │
│                          │        │                     │
│  kube-scheduler          │        │  ┌───────────────┐  │
│   (places pods on nodes) │        │  │ Pod  Pod  Pod │  │
│                          │        │  └───────────────┘  │
│  controller-manager      │        └─────────────────────┘
│   (reconciliation loops) │        ┌─────────────────────┐
│                          │        │  more nodes...      │
│  cloud-controller-manager│        └─────────────────────┘
│   (talks to cloud APIs)  │
└──────────────────────────┘

On managed services (EKS, AKS, GKE) the cloud provider runs the control plane. You manage nodes and workloads. That division is exactly why Terraform is useful here: the cluster itself is a cloud resource with an API, which is Terraform’s home territory.

Core objects

ObjectWhat it does
PodSmallest deployable unit; one or more containers sharing network and storage
DeploymentManages ReplicaSets; handles rolling updates and rollbacks
ServiceStable network endpoint and load balancing across pods
IngressHTTP/HTTPS routing from outside the cluster
ConfigMapNon-sensitive configuration
SecretSensitive data (base64-encoded, encrypt at rest)
StatefulSetWorkloads needing stable identity and storage
DaemonSetOne pod per node — agents, log shippers, CNI
HorizontalPodAutoscalerScales replica count based on metrics

Scheduling, scaling, self-healing

Scheduling: the scheduler places pods based on resource requests, node affinity, taints and tolerations, and topology constraints.

Scaling: horizontally via HPA (more pods), vertically via VPA (bigger pods), or at the infrastructure layer via Cluster Autoscaler or Karpenter (more nodes). Note that node-level scaling touches infrastructure Terraform may also manage — more on that conflict shortly.

Self-healing: failed containers restart, unhealthy pods get replaced, pods on dead nodes reschedule, and readiness probes keep traffic away from pods that aren’t ready.

Common use cases

  • Running microservices with independent scaling
  • Zero-downtime rolling deployments
  • Batch and scheduled jobs
  • Workloads that need to survive node failure without paging anyone
  • Multi-tenant platforms with namespace isolation
  • Anything where you’d otherwise write scripts to restart crashed processes

Terraform vs Kubernetes: full comparison

DimensionTerraformKubernetes
Primary purposeInfrastructure provisioningContainer orchestration
What it managesCloud resources: VPCs, VMs, databases, IAM, clustersContainers, workloads, in-cluster networking and config
Abstraction levelInfrastructure layer (below the OS)Application layer (above the OS)
State managementExplicit state file, remote backend, lockingDistributed state in etcd, continuously reconciled
Execution modelRuns on demand, then exitsRuns continuously as a control loop
AutomationPipeline-triggered; plan then applyAlways-on; self-healing without human trigger
ScalingProvisions capacity (node groups, instance counts)Scales workloads within available capacity
NetworkingCreates VPCs, subnets, load balancers, security groupsManages pod networking, Services, Ingress, NetworkPolicy
Config languageHCLYAML (or JSON)
Typical usersPlatform, infrastructure, cloud engineersPlatform engineers, SREs, application developers
Learning curveModerate. Concepts are few; state and modules take timeSteep. Many interacting components and failure modes
Drift handlingDetects at plan time; you decide what to doCorrects automatically, continuously
Failure modelApply fails, you fix config and re-runControllers retry indefinitely; rollouts can be rolled back
Best forAnything with a cloud API and a slow lifecycleAnything containerized with a fast lifecycle
Main advantagePreview changes before applying; works across every providerSelf-healing and automatic scaling with no human in the loop
Main limitationNo runtime awareness; doesn’t know or care if apps are healthyOnly manages what’s inside the cluster

Where they’re genuinely similar

They genuinely do overlap:

  • Both are declarative — you describe the end state, not the steps to reach it.
  • Both reconcile desired state against actual state.
  • Both are extensible through plugins — providers for Terraform, CRDs and operators for Kubernetes.
  • Both are open source, backed by large communities, proven at scale, and easy to wire into CI/CD.

Where that similarity misleads people

The similarity is real but shallow, and it’s what causes bad architecture decisions.

The difference is when reconciliation happens. Terraform reconciles when you run it. Kubernetes reconciles constantly, in a loop, whether or not anyone is watching.

That single difference is where most of the trouble comes from, so it’s worth going through carefully.

Why the reconciliation models conflict

In plain terms: our construction crew (Terraform) expects the building to match the blueprint it was handed. Our building manager (Kubernetes) is constantly making changes — moving people around, opening more rooms as demand rises. Tell the crew to keep enforcing the original blueprint and it will keep tearing out the manager’s work, and the two get stuck undoing each other. That’s the entire conflict. Everything below is just the detail of how it plays out.

Here’s the mechanism that most comparisons skip.

Terraform assumes it is the sole author of the resources it manages. Its model is: this config describes reality, and any difference is drift I should correct on the next apply.

Kubernetes assumes nothing is the sole author. Controllers, operators, admission webhooks, the scheduler, and autoscalers all mutate resources continuously and by design. Mutation isn’t an exception in Kubernetes; it’s the operating principle.

Point Terraform at Kubernetes resources and these two assumptions collide.

The HPA trap

HPA — the Horizontal Pod Autoscaler — is Kubernetes’ built-in autoscaler: it adds and removes copies of an app as traffic rises and falls. It’s the building manager calling for more rooms at rush hour. Watch what happens when Terraform also believes it owns that number.

Suppose you manage a Deployment through Terraform:

resource "kubernetes_deployment" "api" {
  metadata {
    name      = "api"
    namespace = "production"
  }
  spec {
    replicas = 3          # Terraform believes this
    # ... template omitted
  }
}

Then you add a HorizontalPodAutoscaler, because you want the app to handle traffic spikes:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: api
  namespace: production
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api
  minReplicas: 3
  maxReplicas: 20
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70

Traffic arrives. HPA scales to 12 replicas. Correct behavior — that’s the entire point of HPA (Kubernetes HPA docs).

Now someone runs terraform plan:

~ resource "kubernetes_deployment" "api" {
    ~ spec {
        ~ replicas = 12 -> 3
      }
  }

Terraform wants to scale you back down to 3, in the middle of your traffic spike, because its config says 3. Apply that and you’ve caused an incident. HPA then scales back up, Terraform sees drift again, and the two keep overwriting each other indefinitely.

The mitigation is ignore_changes:

resource "kubernetes_deployment" "api" {
  # ...
  lifecycle {
    ignore_changes = [spec[0].replicas]
  }
}

That works. It’s also a signal. You’ve told Terraform to stop managing part of a resource it nominally owns, which means ownership is split in a way nobody can see from the code.

Phantom drift

The HPA case is the obvious one. Subtler versions appear everywhere:

  • Admission webhooks inject sidecars, labels, or annotations. Terraform sees fields it didn’t write.
  • Operators modify the CRs they manage. Terraform sees changes it wants to revert.
  • Defaulting — the API server populates dozens of fields you never specified.
  • Cluster autoscaler / Karpenter changes node counts that Terraform’s node group config also declares.

Each of these produces a diff in terraform plan that looks like drift and isn’t. Teams learn to skim past plan output, and the moment they start skimming, the plan step has stopped protecting them.

Key insight: The rule that falls out of all this: Terraform should own resources whose state only changes when a human changes it. Kubernetes should own resources that change on their own. Nearly every boundary question resolves cleanly against that test.

How Terraform and Kubernetes work together

The production workflow

  1. Terraform provisions foundational cloud infrastructure — VPC, subnets, routing, security groups, NAT gateways.
  2. Terraform creates the Kubernetes cluster — EKS/AKS/GKE control plane and node groups.
  3. Terraform configures networking and identity — IAM roles, IRSA or Workload Identity, load balancer controllers, DNS.
  4. Terraform bootstraps cluster add-ons — CNI, CSI drivers, ingress controller, cert-manager, and the GitOps agent itself.
  5. Kubernetes (via GitOps) deploys and manages applications — Deployments, Services, Ingress, ConfigMaps, HPAs.
  6. CI/CD updates workloads continuously — image builds push new tags; ArgoCD or Flux reconciles them into the cluster.
  7. Observability spans both layers — because incidents rarely respect the boundary.

The layered stack

┌──────────────────────────────────────────────┐
│  APPLICATIONS          owned by: GitOps      │
│  Deployments, Services, Ingress, HPA,        │
│  ConfigMaps, Secrets                         │
├──────────────────────────────────────────────┤
│  CLUSTER ADD-ONS       owned by: both        │
│  CNI, CSI, ingress controller, cert-manager, │
│  ArgoCD  ── Terraform bootstraps,            │
│             GitOps maintains                 │
├──────────────────────────────────────────────┤
│  KUBERNETES CLUSTER    owned by: Terraform   │
│  Control plane, node groups, cluster IAM     │
├──────────────────────────────────────────────┤
│  CLOUD INFRASTRUCTURE  owned by: Terraform   │
│  VPC, subnets, RDS, S3, IAM, load balancers  │
└──────────────────────────────────────────────┘

Changes get slower and rarer as you go down. Applications deploy many times a day. VPCs change a few times a year. That cadence difference is why they need separate pipelines.

Provisioning managed clusters

AWS EKS

Most teams use the community module rather than assembling resources by hand:

module "eks" {
  source  = "terraform-aws-modules/eks/aws"
  version = "~> 21.0"

  # v21 renamed cluster_name -> name and cluster_version -> kubernetes_version.
  # The old names are removed, not aliased, so v20 config errors on upgrade.
  name               = "production"
  kubernetes_version = "1.34"

  vpc_id     = module.vpc.vpc_id
  subnet_ids = module.vpc.private_subnets

  eks_managed_node_groups = {
    general = {
      min_size       = 3
      max_size       = 10
      desired_size   = 3
      instance_types = ["m6i.large"]
    }
  }
}

Note desired_size. Cluster Autoscaler will change it, and Terraform will want to change it back — the node-group version of the HPA trap. Either add ignore_changes, or use Karpenter and let it own node provisioning entirely.

Azure AKS

resource "azurerm_kubernetes_cluster" "main" {
  name                = "production"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
  dns_prefix          = "production"

  default_node_pool {
    name       = "system"
    node_count = 3
    vm_size    = "Standard_D4s_v5"
  }

  identity {
    type = "SystemAssigned"
  }
}

Google GKE

The common pattern separates cluster from node pools, so node pool changes don’t force cluster replacement:

resource "google_container_cluster" "main" {
  name     = "production"
  location = "us-central1"

  # You can't create a cluster with zero node pools, so create the default
  # and immediately remove it, then manage pools as separate resources.
  remove_default_node_pool = true
  initial_node_count       = 1

  # Defaults to true since provider 5.0; destroy fails without this.
  deletion_protection = false
}

resource "google_container_node_pool" "general" {
  name       = "general"
  cluster    = google_container_cluster.main.id
  node_count = 3

  node_config {
    machine_type = "e2-standard-4"
  }
}

Self-managed Kubernetes

Terraform provisions VMs, networking, and load balancers for the API server. Cluster installation itself goes to kubeadm, kOps, Cluster API, or Ansible. Terraform stops at the machines.

The reason to do this is usually a regulatory or on-prem constraint. It’s substantially more operational work — you now own control plane upgrades, etcd backups, and certificate rotation.

The ambiguous middle: cluster add-ons

This is where teams genuinely disagree, and where “they complement each other” stops being useful advice.

Cluster add-ons — CNI, CSI drivers, ingress controllers, cert-manager, the metrics server, ArgoCD — sit between infrastructure and application. They’re installed into Kubernetes, which suggests GitOps. But some must exist before GitOps can run at all, which suggests Terraform.

The pattern that works: Terraform bootstraps, GitOps maintains.

Add-onBootstrapSteady stateWhy
CNI pluginTerraformTerraformCluster is non-functional without it
CSI driversTerraformTerraformNeeds cloud IAM Terraform already manages
AWS Load Balancer ControllerTerraformTerraformTightly coupled to IRSA and cloud resources
cert-managerTerraformGitOpsNeeds DNS/IAM initially, then routine
Ingress controllerTerraformGitOpsProvisions a cloud LB, then app-adjacent
ArgoCD / FluxTerraformSelf-managedChicken-and-egg: GitOps can’t install GitOps
Monitoring stackTerraformGitOpsNeeds storage and IAM, then iterates fast
Application workloadsGitOpsGitOpsNever Terraform

Rule of thumb: if it needs cloud IAM or provisions cloud resources, Terraform. If it’s configured frequently by people who don’t have cloud credentials, GitOps.


One practical consequence of this split: when something breaks, the evidence lives in two places. Terraform-provisioned infrastructure emits metrics to your cloud provider’s monitoring. Kubernetes workloads emit to your cluster’s telemetry stack. An incident that starts as “checkout is slow” might be an HPA that can’t scale because the node group hit its Terraform-defined ceiling — and correlating those two facts means querying two systems that share no common identifiers.

Obsium builds the observability layer that spans both, so cluster events and the infrastructure underneath them land in one queryable place with consistent labels. If your last cross-layer incident took hours to diagnose, book a free 30-minute consultation.

Real-world example: microservices on EKS

A concrete walkthrough. An e-commerce platform: three microservices, a Postgres database, public HTTPS traffic, autoscaling.

What Terraform provisions

# Networking
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 6.0"

  name = "prod"
  cidr = "10.0.0.0/16"

  azs             = ["us-east-1a", "us-east-1b", "us-east-1c"]
  private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
  public_subnets  = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]

  enable_nat_gateway = true
  single_nat_gateway = false   # one per AZ for HA

  # Role tags the AWS Load Balancer Controller uses for subnet discovery.
  # The older kubernetes.io/cluster/<name> tag is now optional.
  public_subnet_tags  = { "kubernetes.io/role/elb" = "1" }
  private_subnet_tags = { "kubernetes.io/role/internal-elb" = "1" }
}

# Database
resource "aws_db_instance" "postgres" {
  identifier     = "prod-orders"
  engine         = "postgres"
  engine_version = "17.10"
  instance_class = "db.r6g.large"

  allocated_storage     = 100
  storage_encrypted     = true
  multi_az              = true
  db_subnet_group_name  = aws_db_subnet_group.main.name
  vpc_security_group_ids = [aws_security_group.rds.id]

  backup_retention_period = 30
  skip_final_snapshot     = false
  final_snapshot_identifier = "prod-orders-final"
}

# IAM for a workload, scoped to one service account (IRSA)
module "orders_irsa" {
  # Renamed in v6: was iam-role-for-service-accounts-eks
  source  = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts"
  version = "~> 6.0"

  name            = "orders-service"
  use_name_prefix = false   # defaults to true, which appends a random suffix

  oidc_providers = {
    main = {
      provider_arn               = module.eks.oidc_provider_arn
      namespace_service_accounts = ["production:orders"]
    }
  }
}

What Kubernetes deploys

apiVersion: apps/v1
kind: Deployment
metadata:
  name: orders
  namespace: production
spec:
  replicas: 3
  selector:
    matchLabels:
      app: orders
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0        # no capacity loss during rollout
  template:
    metadata:
      labels:
        app: orders
    spec:
      serviceAccountName: orders
      containers:
        - name: orders
          image: registry.example.com/orders:1.8.3
          ports:
            - containerPort: 8080
          env:
            - name: DB_HOST
              valueFrom:
                configMapKeyRef:
                  name: orders-config
                  key: db_host
            - name: DB_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: orders-db
                  key: password
          resources:
            requests:
              cpu: 200m
              memory: 256Mi
            limits:
              memory: 512Mi
          readinessProbe:
            httpGet:
              path: /healthz
              port: 8080
            initialDelaySeconds: 5
          livenessProbe:
            httpGet:
              path: /healthz
              port: 8080
            initialDelaySeconds: 15
---
# The IngressClass must exist before any Ingress can reference it.
# It isn't created automatically just because you name it "alb".
apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
  name: alb
spec:
  controller: ingress.k8s.aws/alb
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: storefront
  namespace: production
  annotations:
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/target-type: ip
spec:
  ingressClassName: alb
  rules:
    - host: shop.example.com
      http:
        paths:
          - path: /orders
            pathType: Prefix
            backend:
              service:
                name: orders
                port:
                  number: 80

Note the interaction: the Ingress creates a real AWS Application Load Balancer, using subnets Terraform tagged and IAM permissions Terraform granted. Kubernetes is provisioning cloud infrastructure through a controller that Terraform set up. The layers aren’t sealed off from each other; they hand off.

Who owns what

ResourceOwnerWhy
VPC, subnets, NATTerraformCloud API, changes rarely
RDS instanceTerraformCloud API, stateful, careful lifecycle
EKS control planeTerraformCloud API
Node groupsTerraformCloud API (but ignore autoscaler-managed counts)
IAM roles / IRSATerraformCloud API
Subnet tags for ALBTerraformCloud API, and the controller depends on them
AWS Load Balancer ControllerTerraformNeeds IRSA, cluster-critical
DeploymentGitOpsChanges multiple times daily
Service, IngressGitOpsApplication routing
ConfigMapGitOpsApplication config
Secret valuesExternal storeNever in Terraform state or Git
HPAGitOpsActively mutates the Deployment
The ALB the Ingress createsNeither, directlyController-managed; don’t import into Terraform

What happens when you need to change something

Scale the app to handle Black Friday: adjust HPA maxReplicas in Git, ArgoCD syncs it. If nodes run out, Cluster Autoscaler adds them within the Terraform-defined maximum. If you hit that maximum, then you touch Terraform.

Add a database read replica: Terraform change, reviewed as a plan, applied through the infrastructure pipeline. The app picks it up via a ConfigMap change through GitOps. Two pipelines, two cadences, in that order.

Deploy a new version of the orders service: CI builds and pushes the image, updates the tag in Git, ArgoCD syncs, rolling update proceeds with maxUnavailable: 0. Terraform is not involved and shouldn’t be.

Common mistakes

MistakeWhy it hurtsDo instead
Using Terraform to orchestrate applicationsNo rollback, no health checks, no progressive delivery. Terraform doesn’t know if your app worksGitOps (ArgoCD/Flux) for workloads
Managing Deployments in Terraform alongside HPAPerpetual fight over replica count; downscaling during traffic spikesLet Kubernetes own anything a controller mutates
Local state filesOne laptop holds production truthRemote backend with locking, day one
One monolithic state fileHuge blast radius, slow plans, painful conflictsSplit by lifecycle: network / cluster / data / apps
Same repo and pipeline for infra and appsDeploys blocked behind infrastructure reviewSeparate repos, separate pipelines, separate cadence
Unpinned provider versionsProvider update silently changes plan outputPin in required_providers, upgrade deliberately
Bumping a major module version without reading the upgrade guideArguments get renamed and removed, not deprecated. EKS module v21 renamed cluster_namename and cluster_versionkubernetes_version; v20 config fails outrightRead UPGRADE-<version>.md before every major bump
Pinning a Kubernetes version near end of standard supportSilently rolls into extended support pricingTrack the provider’s version support calendar
kubernetes_manifest for CRs before the CRD existsPlan fails; the provider needs the CRD at plan timeInstall CRDs first, or use GitOps for CRs
Secrets in Terraform variablesPlaintext in stateExternal Secrets Operator or cloud secret manager
Importing controller-created resourcesTerraform fights the controller that created themLeave controller-managed cloud resources alone
Skimming plan output because it’s always noisyYou stop noticing real destructive changesFix the noise with ignore_changes; keep plans meaningful

Best practices

Boundary

  • [ ] Terraform owns resources that change only when a human changes them
  • [ ] Kubernetes/GitOps owns anything a controller mutates
  • [ ] Cluster add-ons bootstrapped by Terraform, maintained by GitOps
  • [ ] Ownership documented so nobody has to guess

Terraform hygiene

  • [ ] Remote state with locking, encrypted at rest
  • [ ] State split by lifecycle and blast radius
  • [ ] Provider and module versions pinned
  • [ ] Plan reviewed on every PR, applied only from the pipeline
  • [ ] Modules for anything used more than twice
  • [ ] terraform plan output that’s clean enough to actually read

Kubernetes and GitOps

  • [ ] ArgoCD or Flux reconciling from Git
  • [ ] Separate repositories for infrastructure and applications
  • [ ] Resource requests and limits on every workload
  • [ ] Readiness and liveness probes configured
  • [ ] maxUnavailable: 0 for zero-downtime rollouts

Security

  • [ ] Workload identity (IRSA / Workload Identity) rather than node-level IAM
  • [ ] Secrets in an external store, injected at runtime
  • [ ] Policy as code on both layers (Sentinel/OPA for Terraform, Kyverno/Gatekeeper for Kubernetes)
  • [ ] IaC scanning in CI (tfsec, Checkov, Trivy)

Operations

  • [ ] Drift detection running on a schedule, not just at deploy time
  • [ ] Observability spanning infrastructure and workloads with shared labels
  • [ ] Runbooks that name which tool owns the resource in question

Decision framework: which tool owns this resource?

Work through these in order. The first “yes” gives you the answer.

If it’s…Owner
VPC, subnet, security groupTerraform
Managed database, cache, queueTerraform
Kubernetes cluster and node groupsTerraform
IAM role, policy, service account bindingTerraform
DNS record, TLS certificate (cloud-issued)Terraform
CNI, CSI, load balancer controllerTerraform
ArgoCD/Flux itselfTerraform (then self-managing)
cert-manager, monitoring stackTerraform bootstrap → GitOps
Deployment, StatefulSet, DaemonSetGitOps
Service, Ingress, NetworkPolicyGitOps
ConfigMap, HPA, PodDisruptionBudgetGitOps
NamespaceEither; pick one and be consistent
Secret valuesNeither; external secret store

Platform engineering absorbs both. CNCF’s survey segments organizations by maturity and finds GitOps adoption tracks it closely: 58% among the most mature “innovator” tier, against 23% of “adopters” (CNCF 2025). Increasingly the answer to “Terraform or Kubernetes” is that a platform team owns both and exposes a golden path so application teams touch neither directly. We covered the discipline in platform engineering explained.

GitOps becomes the default for in-cluster. Terraform’s role is settling into provisioning and bootstrap, with continuous reconciliation handled by ArgoCD or Flux.

Control-plane-as-API blurs the line. Crossplane and cloud operators (AWS ACK, Azure Service Operator, GCP Config Connector) let you provision cloud resources through the Kubernetes API. It’s a real pattern, but troubleshooting moves inside the cluster and provider coverage still trails Terraform’s.

AI-assisted IaC arrives with caveats. Generating Terraform and manifests from prompts works reasonably for common patterns. It’s also confidently wrong about IAM scoping and security defaults, so plan review matters more, not less.

The OpenTofu split persists. HashiCorp moved Terraform from MPL 2.0 to the BUSL licence in August 2023. OpenTofu forked from the last MPL-licensed code, corresponding to v1.5.7, and now ships independently. Both are viable, and organizations with licence-sensitive procurement increasingly pick OpenTofu.

Conclusion

Terraform and Kubernetes occupy adjacent layers rather than competing for the same one, and nearly every production Kubernetes environment has Terraform somewhere underneath it.

What to take away:

  • Terraform provisions; Kubernetes orchestrates. Different layers, different lifecycles.
  • The boundary exists for a technical reason. Kubernetes controllers mutate resources continuously; Terraform assumes sole authorship. That conflict is real and it produces phantom drift.
  • The ownership test: Terraform owns what changes only when a human changes it. Kubernetes owns what changes on its own.
  • The HPA trap is the canonical example. If you’re reaching for ignore_changes on Kubernetes resources, ownership is probably in the wrong place.
  • Bootstrap add-ons with Terraform, maintain with GitOps. This resolves most of the ambiguous middle layer.
  • Separate repos and pipelines. Infrastructure and applications change at different speeds.
  • Fix your state management before anything else on this list.

Next steps: audit which tool currently owns each resource type, find anywhere ignore_changes is papering over a boundary problem, confirm state is remote and split sensibly, then separate pipelines if you haven’t.

Where Obsium fits

The boundary looks tidy in a diagram and much less tidy during an incident. Real failures cross it constantly:

  • A pod that won’t schedule because a node group hit a Terraform-defined ceiling.
  • A service failing because an IAM role Terraform manages lost a permission.
  • Latency that turns out to be a subnet routing change from last week’s apply.

Diagnosing those means correlating infrastructure state with cluster telemetry, and by default those live in separate systems with no shared identifiers.

Obsium builds that correlation layer for engineering teams — consistent labeling across both layers, telemetry pipelines that span cloud and cluster, and alerting that routes to whoever owns the resource rather than whoever is on call. If you’re running Terraform-provisioned Kubernetes and your incidents keep turning into archaeology, book a free 30-minute consultation. An engineer will look at your setup and tell you where the visibility gaps are.

FAQs

What is the difference between Terraform and Kubernetes?

Terraform provisions infrastructure through cloud APIs — VPCs, databases, IAM, and Kubernetes clusters themselves. Kubernetes orchestrates containers running on infrastructure. Terraform builds the environment; Kubernetes runs the applications inside it.

Can Terraform replace Kubernetes?

No. Terraform has no runtime component. It can’t restart a crashed container, shift traffic away from an unhealthy pod, or scale in response to load. It runs, makes changes, and exits.

Can Kubernetes replace Terraform?

Partially, through Crossplane or cloud operators like AWS ACK, which let you manage cloud resources via the Kubernetes API. In practice provider coverage is narrower and troubleshooting is harder, since a successful kubectl apply only means the resource was accepted, not that the cloud resource was created.

Do you need Terraform if you use Kubernetes?

You need something to create the cluster and the surrounding cloud resources. That could be Terraform, OpenTofu, Pulumi, CloudFormation, or a CLI. Terraform is the common choice because it handles the whole estate, not just the cluster.

Should I use Terraform to deploy Kubernetes applications?

Generally no. Terraform lacks rollback, health-gated rollouts, and progressive delivery, and it fights controllers that mutate your resources. Use GitOps for workloads. The exception is a small number of bootstrap resources that must exist before GitOps runs.

What is the Terraform Kubernetes provider used for?

Legitimately: bootstrapping cluster-critical resources before GitOps exists, and creating resources that need values Terraform holds (like an IAM role ARN). Not for ongoing application management.

What is the difference between Terraform state and Kubernetes desired state?

Terraform state is a file recording what it created, compared against config when you run it. Kubernetes desired state lives in etcd and is compared against reality continuously by controllers. One is a periodic checkpoint; the other is a permanent control loop.

Is Terraform still relevant given Kubernetes operators?

Yes. Operators cover a fraction of cloud services, are less mature, and complicate troubleshooting by moving failures into controller logs. Terraform’s plan step also has no operator equivalent — you see the diff before anything changes.

Leave a Comment

Your email address will not be published. Required fields are marked *