Terraform Lesson 60 of 89

Terraform on AWS EKS: the kubernetes & helm Providers, App Deployment & the GitOps Handoff

You have an EKS cluster. terraform apply finished, kubectl get nodes shows three green nodes, and you feel done. You are roughly half done. A Kubernetes cluster with nothing running on it is an expensive way to heat a data centre; the value is in what runs inside it — the ingress controller that gives your services a load balancer, the secrets operator that pulls credentials from AWS Secrets Manager, the DNS controller that publishes records, and eventually the applications themselves. The moment you try to make Terraform reach inside the cluster to install those things, you cross into the single most error-prone territory in all of Terraform: the kubernetes and helm providers, configured from a cluster that Terraform itself is managing. Get the authentication wrong and your apply succeeds today and fails Unauthorized in fifteen minutes. Get the ordering wrong and a clean apply dies with Provider configuration ... cannot be determined until apply. Get the boundary wrong and you build a platform where every application deploy is a terraform apply that app teams are terrified to run.

This lesson is about doing all three correctly, and then knowing where to stop. The strong industry pattern — the one every mature platform team converges on — is a division of labour: Terraform provisions the platform (the cluster, its add-ons, the controllers, the IAM/IRSA wiring), and then hands off application delivery to GitOps — Argo CD or Flux, bootstrapped by Terraform but thereafter driven by Git. Terraform is superb at building the pieces of a cluster and terrible at continuously reconciling twenty microservices that deploy forty times a day; Argo CD is the reverse. The art is drawing the line between them in the right place, and then having Terraform bootstrap the thing that owns the other side of the line. That is the whole arc of this lesson: providers, ordering, app deployment, and the handoff.

By the end you will have run a complete, copy-pasteable, two-stack demo yourself: a cluster stack (from the companion lesson) whose outputs feed an apps stack that configures the kubernetes and helm providers over an exec block, installs a real controller and a sample app with helm_release, then bootstraps Argo CD plus a root Application pointing at a Git repository — the handoff, live. You will run init → plan → apply → verify → destroy, and you will leave with four reference tables you will come back to for years: exec-vs-data-source auth, the ordering patterns, helm_release versus kubernetes_manifest, and the Terraform-versus-GitOps boundary. Every failure that actually pages people — provider-unknown-at-plan, token-expiry drift, CRD-at-plan-time, secret-in-state, a Helm release stuck pending, and destroy ordering — gets its own row in the troubleshooting table and a paragraph on how to escape it.

What you’ll build

The scenario is the one every team hits the week after the cluster goes up: “great, now put the platform on it.” Concretely you will build a second Terraform stack — separate from the one that created the cluster — that does four jobs. First, it configures the kubernetes and helm providers to authenticate to your existing EKS cluster using the cluster’s API endpoint, its base64-decoded certificate authority, and an exec credential plugin that shells out to aws eks get-token. Second, it installs a platform controller (the AWS Load Balancer Controller) and a sample application (podinfo) as helm_release resources, wiring the controller’s IAM permissions through IRSA. Third, it bootstraps Argo CD with another helm_release. Fourth — the punchline — it creates a single root Argo CD Application that points at a Git repository, after which every future application is delivered by Argo CD from Git, not by Terraform. Terraform builds the platform; Git drives the apps.

Why split it this way rather than cram everything into one giant terraform apply that builds the cluster and installs everything and deploys the apps? Because the three layers have wildly different change rates and blast radii, and because the providers physically cannot be configured reliably from a cluster created in the same run. The comparison below is the argument in one table — it is the reasoning you will repeat to every engineer who asks “why can’t I just do it all in main.tf”:

Approach Repeatable? Provider auth reliable? App deploy cadence Right for
Portal / kubectl apply by hand No — click/command path is not code n/a Ad hoc, undocumented A throwaway experiment
eksctl + Helm CLI scripts Scriptable, imperative Manual kubeconfig Manual, per-engineer Quick labs, glue
One Terraform apply: cluster + add-ons + apps Yes, but fragile No — provider from unknown cluster terraform apply per deploy Nothing in production
Two Terraform stacks: cluster, then platform/apps Yes Yes — apps stack reads a live cluster terraform apply per deploy Platform, before GitOps
Terraform platform → GitOps for apps (this lesson) Yes Yes git push per deploy Production platform teams

The last two rows are the journey of this lesson: you start by proving Terraform can deploy apps (two stacks), then you deliberately hand that job to Argo CD because Terraform is the wrong tool for continuous app delivery. Here is the full inventory of what the apps stack builds, so you can see the moving parts before the code:

Component Terraform resource / provider Role
kubernetes provider provider "kubernetes" Talks to the EKS API for raw objects
helm provider provider "helm" Installs charts onto the cluster
Cluster lookup data "aws_eks_cluster" + terraform_remote_state Endpoint, CA, name from the cluster stack
Token (preferred) exec { command = "aws" ... get-token } Fresh, non-expiring-in-state credential
Namespace kubernetes_namespace A place to put things
Sample app helm_release "podinfo" Proves the providers work end to end
ALB Controller helm_release "aws_load_balancer_controller" Platform controller (needs IRSA)
IRSA role aws_iam_role + OIDC trust Keyless AWS auth for the controller pod
External Secrets Operator helm_release "external_secrets" So secrets never enter Terraform state
Argo CD helm_release "argocd" The GitOps engine, bootstrapped by TF
Root Application helm_release "root_app" (argocd-apps) The App-of-Apps that hands off to Git

And here is the same picture as a division of labour between the two stacks — the mental model to keep as you read the code:

Layer Cluster stack (upstream lesson) Apps stack (this lesson)
State key eks/cluster/terraform.tfstate eks/apps/terraform.tfstate
VPC, subnets, node groups
EKS control plane + OIDC provider
kubernetes / helm providers ✅ (exec auth)
Controllers (ALB, ExternalDNS, ESO) ✅ (helm_release)
Argo CD + root App-of-Apps ✅ (bootstrap, then Git)
Change cadence Rare — cluster lifecycle Moderate, then continuous via Git

Left-to-right EKS provider and GitOps handoff architecture: Terraform configures the kubernetes and helm providers with an exec block calling aws eks get-token against the EKS API endpoint and cluster CA, installs platform add-ons and controllers with helm_release into the cluster, then bootstraps Argo CD and a root App-of-Apps which continuously reconciles application workloads from a Git repository

Read the diagram left to right: Terraform (badge 2 — the cluster and apps live in two separate stacks) configures the kubernetes and helm providers with an exec block that mints a fresh token, not a stale one (badge 1); those providers reach the EKS API using its endpoint and CA; Terraform installs the platform controllers with helm_release (badge 3) and wires External Secrets so credentials never land in state (badge 4); then it bootstraps Argo CD (badge 6) and crosses the Terraform → GitOps boundary (badge 5), after which Argo CD reconciles every application from Git. The six legend entries are the six decisions the rest of this lesson makes in code. This lesson assumes the cluster itself already exists, built the way the EKS cluster provisioning lesson lays it out (VPC, managed node groups, the OIDC provider that IRSA depends on), and that your AWS provider auth and S3/DynamoDB backend are configured per the Getting Started on AWS lesson.

The kubernetes and helm providers: authenticating to EKS

The kubernetes and helm providers are, at bottom, Kubernetes API clients wearing Terraform costumes. To do anything they need what any kubectl needs: the API server’s URL, the CA certificate to trust it, and a credential to prove who you are. EKS hands you the first two as cluster attributes and expects you to produce the third yourself — and how you produce it is the decision that separates a config that works forever from one that breaks every fifteen minutes.

Start with what the cluster exposes. Whether you read the cluster with a data "aws_eks_cluster" source or straight off the resource, three attributes matter:

Attribute Contains Feeds provider argument Note
endpoint https://XXXX.gr7.<region>.eks.amazonaws.com host The API server URL
certificate_authority[0].data base64-encoded PEM CA bundle cluster_ca_certificate (after base64decode) Must decode — a raw paste fails TLS
name The cluster name exec args / aws_eks_cluster_auth.name Used to fetch the token
identity[0].oidc[0].issuer The OIDC issuer URL IRSA trust policies Not for the provider; for IAM

The base64 trap catches everyone once. certificate_authority[0].data is base64-encoded PEM, and the cluster_ca_certificate provider argument wants decoded PEM. Feed it the raw value and TLS fails with x509: certificate signed by unknown authority or a decode error; you must wrap it in base64decode(...). That is not optional and it is the single most common copy-paste bug in EKS provider configs.

Those three cluster attributes feed a small, fixed set of provider arguments — the same names on both the kubernetes and helm providers:

Provider argument What it is Source for EKS
host API server URL data.aws_eks_cluster.this.endpoint
cluster_ca_certificate PEM CA used to trust the API server base64decode(...certificate_authority[0].data)
token A static bearer token aws_eks_cluster_auth.this.token — ⚠️ 15-min expiry
exec A credential plugin run on every operation aws eks get-tokenpreferred
config_path / config_context Point at an existing kubeconfig Laptop convenience; avoid in CI
insecure Skip TLS verification Never in production

The last two rows exist mostly so you can rule them out: config_path couples the run to a kubeconfig on disk (fine on a laptop, a landmine in CI), and insecure = true disables the very TLS check the CA is there to perform. That leaves the real choice between token and exec.

Now the credential — the real decision. There are two ways to give the provider a token, and they are not equivalent:

The provider-auth decision: exec vs the aws_eks_cluster_auth data source

Option A — the exec credential plugin (preferred). You give the provider an exec block that Terraform runs every time it needs to talk to the cluster, shelling out to aws eks get-token --cluster-name <name>, which returns a short-lived token. The crucial property: the token is generated at connection time and is never stored in Terraform state or the plan. It cannot go stale between plan and apply because it is minted fresh for each.

provider "kubernetes" {
  host                   = data.aws_eks_cluster.this.endpoint
  cluster_ca_certificate = base64decode(data.aws_eks_cluster.this.certificate_authority[0].data)

  exec {
    api_version = "client.authentication.k8s.io/v1beta1"
    command     = "aws"
    args = [
      "eks", "get-token",
      "--cluster-name", data.aws_eks_cluster.this.name,
      "--region", var.region,
    ]
    # Optional: pin a profile or role so CI and laptops agree.
    # env = { AWS_PROFILE = "platform" }
  }
}

Each field of that exec block earns its place:

exec field Value for EKS Why
api_version client.authentication.k8s.io/v1beta1 The client-go exec protocol; not the dead v1alpha1
command aws Requires AWS CLI v2 on the runner/laptop
args ["eks","get-token","--cluster-name",<name>,"--region",<r>] The actual token request
env e.g. { AWS_PROFILE = "platform" } Pin the profile/role so CI and laptops resolve the same identity

Option B — the aws_eks_cluster_auth data source (the trap). This data source computes a token by presigning an STS GetCallerIdentity request. It works, and you will see it in older blog posts and modules:

# ⚠️ WORKS, BUT THE TOKEN EXPIRES IN ~15 MINUTES.
data "aws_eks_cluster_auth" "this" {
  name = data.aws_eks_cluster.this.name
}

provider "kubernetes" {
  host                   = data.aws_eks_cluster.this.endpoint
  cluster_ca_certificate = base64decode(data.aws_eks_cluster.this.certificate_authority[0].data)
  token                  = data.aws_eks_cluster_auth.this.token   # <-- computed at plan, baked into state
}

The token from aws_eks_cluster_auth is valid for roughly 15 minutes. It is computed during plan (or during the refresh phase) and written into the plan and state as a literal string. If more than fifteen minutes pass between plan and apply — a code review on the PR, a slow CI queue, a coffee — the token is dead when apply runs and you get Unauthorized. It also produces perpetual “drift”: every refresh/plan recomputes a new token, so the data source is always “changing.” It is not wrong so much as fragile, and there is a strictly better option, so use exec unless you have a specific reason not to (for example, an execution environment where you cannot run the aws binary). The full comparison:

Dimension exec plugin (aws eks get-token) aws_eks_cluster_auth data source
When token is generated At connect time, every operation At plan/refresh time, once
Stored in state/plan? No Yes (a literal token string)
Expiry risk None — always fresh ~15 min — dies between plan & apply
Plan noise / drift None Token “changes” every plan
Requires aws CLI on the runner Yes No (pure Terraform + STS)
Works with assumed roles / SSO Yes (honours the AWS CLI’s chain) Yes (uses the provider’s creds)
Recommended for Almost everything Only when you cannot exec a binary
Sensitive value leak Minimal (nothing persisted) Token sits in state until next apply

The exec protocol version is worth a word: use api_version = "client.authentication.k8s.io/v1beta1". The older v1alpha1 is long gone, and aws eks get-token speaks v1beta1. If you see exec plugin: invalid apiVersion, that mismatch is why.

Configuring the helm provider (and the 2.x vs 3.x syntax split)

The helm provider wraps Helm and needs the exact same connection settings — but where they go changed between major versions, and this trips people who copy a snippet that assumes the other version. In the widely-deployed helm ~> 2.x provider, the Kubernetes connection is a nested kubernetes { ... } block:

# helm provider 2.x — nested kubernetes { } block
provider "helm" {
  kubernetes {
    host                   = data.aws_eks_cluster.this.endpoint
    cluster_ca_certificate = base64decode(data.aws_eks_cluster.this.certificate_authority[0].data)
    exec {
      api_version = "client.authentication.k8s.io/v1beta1"
      command     = "aws"
      args        = ["eks", "get-token", "--cluster-name", data.aws_eks_cluster.this.name, "--region", var.region]
    }
  }
}

The helm ~> 3.x provider (shipped 2025) flattens this to a single kubernetes = { ... } attribute (note the =), and exec becomes a nested attribute too:

# helm provider 3.x — kubernetes = { } attribute (note the equals sign)
provider "helm" {
  kubernetes = {
    host                   = data.aws_eks_cluster.this.endpoint
    cluster_ca_certificate = base64decode(data.aws_eks_cluster.this.certificate_authority[0].data)
    exec = {
      api_version = "client.authentication.k8s.io/v1beta1"
      command     = "aws"
      args        = ["eks", "get-token", "--cluster-name", data.aws_eks_cluster.this.name, "--region", var.region]
    }
  }
}

The demo below pins ~> 2.17 for the classic block syntax you will see in 90% of modules today, but the table tells you how to translate if you are on 3.x:

helm ~> 2.x helm ~> 3.x
Connection shape kubernetes { ... } block kubernetes = { ... } attribute
exec shape exec { ... } block exec = { ... } attribute
Config source HCL block syntax HCL attribute/object syntax
Released Mainstream since 2021 2025
Behaviour Identical Helm actions Identical Helm actions
Migration gotcha Add =, wrap in {} as an object

Both providers, on both versions, run identically on OpenTofu — the kubernetes, helm, and aws providers are the same binaries. Nothing in this lesson is Terraform-vs-OpenTofu specific; assume Terraform ≥ 1.6 or OpenTofu ≥ 1.6 throughout. Pin every provider in required_providers with ~> so a surprise major (like helm 2 → 3) never lands on you mid-sprint:

terraform {
  required_version = ">= 1.6"
  required_providers {
    aws        = { source = "hashicorp/aws",        version = "~> 5.60" }
    kubernetes = { source = "hashicorp/kubernetes",  version = "~> 2.31" }
    helm       = { source = "hashicorp/helm",        version = "~> 2.17" }
  }
}

The ordering problem: why cluster + apps in one apply is fragile

Here is the failure that defines this whole topic. It is tempting — obvious, even — to write one configuration that creates the EKS cluster with aws_eks_cluster and, in the same files, configures the kubernetes provider from that cluster and drops a helm_release on top. One apply, cluster and app together. It looks clean. It is a trap, and understanding exactly why is the difference between a platform engineer and someone who will spend a Saturday on it.

The root cause is Terraform’s evaluation model. Provider configurations are evaluated before resources are created, and a provider block cannot depend on values that only exist after an apply. When you write host = aws_eks_cluster.this.endpoint and the cluster does not exist yet, endpoint is an unknown value at plan time. Terraform cannot configure a provider from an unknown, so on a clean apply you get the notorious error. Even when it seems to work (because the values happen to be known from a prior state), it is fragile: the provider is configured from resources in the same graph, so destroys invert badly and any change that touches the cluster ripples into every in-cluster resource. The specific ways it breaks:

Failure mode Why it happens Symptom you see
Provider from unknown value Provider config references a not-yet-created cluster attribute Provider configuration ... cannot be determined until apply / Invalid attribute in provider configuration
kubernetes_manifest at plan It does a live server-side dry-run against the API dial tcp ...: connect: connection refused during plan
Destroy inversion Terraform may plan to delete the cluster before the workloads on it Hung or errored destroy, orphaned finalizers
One giant blast radius An app value change forces a re-plan of the whole cluster Slow, terrifying applies; nobody wants to run them
helm_release orphans Cluster destroyed first; Helm can’t reach the API to clean up Kubernetes cluster unreachable on destroy

There are three ways to deal with ordering, and only one of them is a real production answer. Know all three because you will meet all three in other people’s code:

Pattern How Verdict
Two stacks (two states) Cluster stack applies first; apps stack reads it via terraform_remote_state / data.aws_eks_cluster and configures providers from a live cluster The right answer. Independent lifecycles, clean destroy, small blast radius
depends_on on the provider-feeding resource Force the cluster to exist before in-cluster resources Helps sequencing but does not fix provider-from-unknown; still one blast radius
-target the cluster first, then apply the rest terraform apply -target=aws_eks_cluster.this then a full apply A manual escape hatch, not a workflow. Fine once; never automate it

The winning pattern is two root modules with two state files. The cluster stack builds the cluster (and often the core VPC and node groups) and outputs the handful of values the apps stack needs. The apps stack reads those, configures the providers against an already-existing cluster — so every attribute is known — and installs everything in-cluster. Their lifecycles are independent: you can redeploy every app forty times without ever re-planning the cluster, and a terraform destroy of the apps stack cleanly removes workloads while the cluster is still up to receive the API calls.

The join is terraform_remote_state, reading the cluster stack’s outputs from its S3 backend:

# apps stack — data.tf
data "terraform_remote_state" "cluster" {
  backend = "s3"
  config = {
    bucket = "kv-tf-state-ap-south-1"
    key    = "eks/cluster/terraform.tfstate"
    region = "ap-south-1"
  }
}

# Re-read the cluster directly for its live endpoint/CA/name.
data "aws_eks_cluster" "this" {
  name = data.terraform_remote_state.cluster.outputs.cluster_name
}

You have two ways to get the cluster’s endpoint and CA into the apps stack, and it is worth knowing the trade-off:

Source of endpoint/CA Pros Cons
terraform_remote_state.cluster.outputs.* One read; couples apps stack to the cluster stack’s outputs Values are as of the cluster’s last apply (usually fine — endpoint/CA are stable)
data "aws_eks_cluster" (fresh AWS API read) Always current; decoupled from what the cluster stack chose to output An extra AWS API call; needs eks:DescribeCluster

A common and robust compromise — the one the demo uses — is to take the cluster name from remote state (a stable string) and then read the endpoint and CA fresh from data "aws_eks_cluster", so the providers always have current connection details even if the cluster stack’s outputs lag. The remote state at scale lesson goes deeper on wiring stacks together with terraform_remote_state and the alternatives (SSM parameters, a data source per resource) when you have many stacks.

Deploying apps: helm_release, kubernetes_manifest, namespaces & secrets

With the providers configured against a live cluster, Terraform can now create objects inside it. You have two families of resources for this, and choosing between them correctly is most of the skill.

helm_release — the workhorse

helm_release installs (and upgrades, and deletes) a Helm chart. It is the right tool for anything that ships as a chart: controllers, operators, add-ons, and most off-the-shelf apps. Its important arguments:

Argument Purpose Note
name The Helm release name Immutable — changing it replaces the release
repository Chart repo URL Omit for an OCI or local chart
chart Chart name (or OCI ref, or local path) e.g. podinfo, oci://.../argo-cd
version Chart version Always pin it — unpinned = silent upgrades
namespace Target namespace Pair with create_namespace
create_namespace Make the namespace if absent true for convenience
values List of raw YAML value documents The main configuration surface
set { name value } Individual overrides For a few scalars; escapes get fiddly
set_sensitive { ... } Override, redacted in output Still lands in state — see below
atomic Roll back on failed install Great for CI — no half-installed releases
wait / timeout Block until resources are ready Default waits; tune timeout for slow charts
repository_username/_password Private repo auth Or use OCI + registry.helm.sh login

There are three ways to feed values into a chart, and idiomatic Terraform uses them in this order of preference:

Method When to use Example
values = [file("...")] A whole values file, unchanged values = [file("${path.module}/values/alb.yaml")]
values = [templatefile(...)] A values file with a few interpolations inject the IRSA role ARN, cluster name, VPC id
values = [yamlencode({...})] Structured values built in HCL good for computed/looped config
set { name = "x" value = "y" } One or two simple scalars set { name = "replicaCount" value = "2" }

templatefile is the one you will lean on for controllers, because a controller almost always needs a value that Terraform computed — an IAM role ARN, the cluster name, a VPC id. Here is the sample app first, kept trivial to prove the providers work, then the real controller:

resource "kubernetes_namespace" "demo" {
  metadata { name = "demo" }
}

resource "helm_release" "podinfo" {
  name       = "podinfo"
  repository = "https://stefanprodan.github.io/podinfo"
  chart      = "podinfo"
  version    = "6.7.1"
  namespace  = kubernetes_namespace.demo.metadata[0].name

  set {
    name  = "replicaCount"
    value = "2"
  }
  set {
    name  = "resources.requests.cpu"
    value = "50m"
  }
}

kubernetes_manifest and the raw-object family

helm_release covers charts; for raw Kubernetes objects Terraform has the kubernetes_* resource family — typed resources for the common objects, plus the generic kubernetes_manifest for anything else:

Resource Creates Reach for it when
kubernetes_namespace A Namespace Placing app/platform objects
kubernetes_config_map A ConfigMap Non-secret config for a controller
kubernetes_service_account A ServiceAccount IRSA binding (annotate with the role ARN)
kubernetes_secret A Secret ⚠️ Avoid for real secrets — lands in state
kubernetes_manifest Any arbitrary object / CRD / CR A one-off raw object with no chart

The typed resources are unremarkable; kubernetes_manifest is the powerful one, and it has a sharp edge that defines how you use it:

⚠️ kubernetes_manifest talks to the cluster at plan time. To compute the plan it fetches the resource’s OpenAPI schema from the live API server (a server-side dry-run). Two consequences: (1) the cluster must already exist and be reachable during plan — which is another reason for the two-stack split; and (2) if the object is a custom resource whose CRD does not yet exist at plan time, the plan fails with no matches for kind "X" in group "Y". You cannot install a CRD and a custom resource of that CRD in the same apply with kubernetes_manifest.

That CRD-at-plan-time constraint is the reason people reach for alternatives when applying custom resources (like an Argo CD Application, which is a CR of the argoproj.io CRDs). The comparison you will use to choose:

Dimension helm_release kubernetes_manifest
Installs A packaged chart (many objects) One arbitrary object
Plan-time cluster contact No (renders locally, applies at apply) Yes — server-side dry-run
Needs CRD to pre-exist No (chart can ship CRD + CR together via hooks/ordering) Yes — CRD must exist at plan
Diffs Coarse (release-level; helm diff externally) Fine-grained per-field
Values / templating Chart values (values, set) Raw HCL map (yamlencode, templatefile)
Best for Controllers, operators, off-the-shelf apps A handful of raw objects when no chart exists
Sharp edge Unpinned version = silent upgrade CRD-at-plan-time; churny on server-managed fields

The practical rule: prefer helm_release for anything chart-shaped, and use kubernetes_manifest sparingly for one-off raw objects whose CRDs already exist. When you need to apply a CR whose CRD is installed in the same run, either (a) use a chart that bundles both, or (b) reach for the community kubectl_manifest resource (from alekc/kubectl or gavinbunney/kubectl), which applies raw YAML at apply time without the plan-time schema fetch — the standard escape hatch for the CRD ordering problem.

The secrets problem: why they land in state, and what to do instead

Here is a footgun with real security consequences. Any secret material you hand to Terraform — a kubernetes_secret’s data, a set_sensitive Helm value, a password in a values file — is written into the Terraform state file in base64, which is not encryption. Anyone who can read state can read the secret. set_sensitive only redacts it from console output; it does nothing about state.

# ⚠️ ANTI-PATTERN — the password is now plaintext-equivalent in state.
resource "kubernetes_secret" "db" {
  metadata{
    name = "db-creds"
    namespace = "demo"
  }
  data = {
    password = var.db_password   # <-- lands base64 in terraform.tfstate
  }
}

The options, worst to best:

Approach Secret in TF state? How it works Verdict
kubernetes_secret with data Yes (base64) Terraform writes the Secret directly Avoid for real secrets
helm_release set_sensitive Yes Redacted in output only Avoid for real secrets
SOPS-encrypted values + sops provider Ciphertext only Terraform decrypts at apply, cipher in git OK; key management overhead
External Secrets Operator + IRSA No ESO pulls from AWS Secrets Manager into the cluster Best — value never touches Terraform
Secrets Store CSI driver (AWS provider) No Mounts SM/SSM secrets as files/env Good; per-pod, no K8s Secret object

The strong pattern is External Secrets Operator (ESO): Terraform installs the operator (a helm_release) and grants it an IRSA role that can read AWS Secrets Manager; then a SecretStore and ExternalSecret (delivered by Git, via Argo CD) tell ESO which secret to sync into which Kubernetes Secret. The actual secret value flows AWS Secrets Manager → ESO → Kubernetes, entirely outside Terraform. Terraform provisions the capability (operator + IAM) and never sees the value. That is the model to internalise: Terraform owns the plumbing for secrets, never the secrets themselves.

Where Terraform should stop: the GitOps handoff

Everything so far proves Terraform can deploy applications. The senior lesson is that for most applications it should not — and knowing where to stop is what makes a platform maintainable. Terraform and a GitOps controller (Argo CD or Flux) are good at opposite things, and the industry has converged on a division of labour that plays to each.

Terraform is a plan-and-apply tool: it computes a diff when you run it, applies it, and then stops thinking about the world until you run it again. That is exactly right for infrastructure that changes rarely and deliberately — a cluster, a node group, an IAM role, an ingress controller. It is exactly wrong for a fleet of applications that deploy dozens of times a day, where you want continuous reconciliation: if someone kubectl edits a Deployment at 3 a.m., you want it snapped back to the declared state within minutes, not “on the next terraform apply, whenever that is.” That continuous reconciliation is precisely what Argo CD and Flux do: they run in the cluster, watch a Git repository, and continuously make the cluster match Git — syncing, pruning removed resources, and self-healing drift.

So the boundary is drawn by change rate and by who owns the change. The reference table you will pin to the wall:

Concern Terraform owns GitOps (Argo CD / Flux) owns
The EKS cluster, VPC, node groups
IAM / IRSA roles, OIDC provider
Cluster-wide controllers (ALB ctrl, ExternalDNS, ESO, cert-manager) ✅ (bootstrap) Sometimes migrated to Git later
Argo CD / Flux itself ✅ (bootstrap) — (it can manage itself after)
Namespaces for app teams ✅ or GitOps
Application Deployments / Services / Ingress
App config maps, HPAs, rollouts
App secrets (via ESO ExternalSecret) — (installs ESO) ✅ (the ExternalSecret manifest)
Change cadence Low, deliberate High, continuous
Reconciliation Only at apply Continuous, self-healing
Who pushes changes Platform engineers App teams (git push)

The head-to-head, so the trade-off is unmistakable:

Dimension Terraform manages apps GitOps manages apps
Deploy trigger terraform apply (pipeline or human) git push → auto-sync
Drift handling Detected only on next plan Continuously corrected (self-heal)
Rollback Revert HCL, re-apply Revert the Git commit; Argo syncs back
Blast radius Whole stack re-planned Per-Application, isolated
Who can deploy Whoever can run Terraform (+ AWS creds) Whoever can merge to the app repo
Secrets Risk of landing in state Stay in ESO/SM, referenced from Git
Audit trail State + CI logs Git history + Argo events
Best fit Cluster + platform Applications

Bootstrapping Argo CD with Terraform (the handoff, in code)

The elegant part: Terraform doesn’t just stop at the boundary — it installs the thing that owns the other side. Terraform bootstraps Argo CD (a helm_release) and creates one root object — the App-of-Apps root Application — that points at a Git repository. After that single apply, Terraform’s job with applications is done forever; Argo CD reads the repo and manages every child Application declared there. New app? Add a manifest to Git; Argo CD deploys it; Terraform never runs.

# 1) Bootstrap Argo CD itself — a normal helm_release.
resource "helm_release" "argocd" {
  name             = "argocd"
  repository       = "https://argoproj.github.io/argo-helm"
  chart            = "argo-cd"
  version          = "7.7.7" # chart version — pin it
  namespace        = "argocd"
  create_namespace = true
  atomic           = true
  timeout          = 600

  values = [yamlencode({
    global = { domain = "argocd.${var.base_domain}" }
    configs = { params = { "server.insecure" = true } } # TLS terminated at the ALB
  })]
}

Now the App-of-Apps. An Argo CD Application is a custom resource of a CRD that the Argo CD chart just installed — so applying it with kubernetes_manifest would hit the CRD-at-plan-time wall on a clean apply (the CRD does not exist at plan). The clean way is to create the root Application with the argocd-apps Helm chart, which templates the Application object and applies it at apply time (after Argo CD’s CRDs exist), sequenced with depends_on:

# 2) The single root App-of-Apps — points Argo CD at your Git repo.
resource "helm_release" "root_app" {
  name       = "root"
  repository = "https://argoproj.github.io/argo-helm"
  chart      = "argocd-apps"
  version    = "2.0.2"
  namespace  = helm_release.argocd.namespace

  values = [yamlencode({
    applications = {
      root = {
        namespace = "argocd"
        project   = "default"
        source = {
          repoURL        = var.gitops_repo_url        # e.g. https://github.com/acme/eks-apps.git
          targetRevision = "main"
          path           = "apps"                     # folder of child Application manifests
        }
        destination = {
          server    = "https://kubernetes.default.svc"
          namespace = "argocd"
        }
        syncPolicy = {
          automated   = { prune = true, selfHeal = true }
          syncOptions = ["CreateNamespace=true"]
        }
      }
    }
  })]

  depends_on = [helm_release.argocd] # CRDs must exist before the CR is applied
}

The fields of that root Application are the entire contract between Terraform and Git — after this, Terraform touches none of it again:

Application field Meaning Demo value
source.repoURL The Git repo Argo CD watches var.gitops_repo_url
source.targetRevision Branch / tag / commit to track main
source.path Folder of child Application manifests apps
destination.server Target cluster API https://kubernetes.default.svc (in-cluster)
destination.namespace Namespace for the child Apps argocd
syncPolicy.automated.prune Delete objects removed from Git true
syncPolicy.automated.selfHeal Revert manual drift back to Git true

automated = { prune = true, selfHeal = true } is the whole philosophy in two flags: prune deletes cluster objects removed from Git, and selfHeal reverts manual changes back to Git’s declared state — continuous reconciliation Terraform structurally cannot do. From here, the workflow for every application is: commit a child Application manifest under apps/ in the Git repo; Argo CD notices, creates it, and keeps it in sync. Terraform’s involvement with applications ended at helm_release.root_app.

A note on Argo CD vs Flux, since the boundary is identical for both:

Argo CD Flux
UI Rich web UI + CLI CLI-first (no built-in UI)
Bootstrap by Terraform helm_release of argo-cd + root Application helm_release/flux_bootstrap_git + Kustomization
App model Application / ApplicationSet (App-of-Apps) Kustomization / HelmRelease (Flux CRDs)
Multi-tenancy AppProject namespaces + RBAC
Best when You want a visual, app-centric console You want a lean, Kustomize-native GitOps

Both are bootstrapped by Terraform the same way and both take over app delivery at the same boundary; pick on team preference, not capability. The ALB controller lesson covers the controller we install below in depth, and the production platform capstone assembles the full picture — cluster, platform add-ons, IRSA, and this GitOps handoff — into one shippable platform.

Hands-on: build it with Terraform

Time to run it. This demo assumes the cluster stack already exists (from the EKS provisioning lesson) with an S3 backend at s3://kv-tf-state-ap-south-1/eks/cluster/terraform.tfstate, exporting cluster_name, oidc_provider_arn, and oidc_provider_url as outputs, and that its OIDC provider is created (IRSA depends on it). We build the apps stack in its own directory with its own state. Region is ap-south-1 (Mumbai). Everything is copy-pasteable.

⚠️ This spends real money. An EKS cluster (~$0.10/hr control plane), node-group EC2, and any ALB the controller creates all bill by the hour. Run the destroy step when you finish.

Step 1 — lay out the apps stack

eks-apps/
├── versions.tf        # providers + backend
├── providers.tf       # aws, kubernetes, helm — the exec auth
├── data.tf            # remote_state + aws_eks_cluster lookups
├── variables.tf
├── platform.tf        # ALB controller (IRSA) + External Secrets Operator
├── apps.tf            # podinfo sample app
├── gitops.tf          # Argo CD + root App-of-Apps
└── outputs.tf
File Purpose
versions.tf Provider pins + the S3 backend (its own eks/apps key)
providers.tf aws + kubernetes + helm, wired with the exec auth
data.tf terraform_remote_state + aws_eks_cluster lookups
variables.tf region, base_domain, gitops_repo_url
platform.tf ALB controller (with IRSA) + External Secrets Operator
apps.tf The podinfo sample helm_release
gitops.tf Argo CD bootstrap + root App-of-Apps
outputs.tf ARNs / namespaces the next layer consumes

Step 2 — versions.tf and the backend

# versions.tf
terraform {
  required_version = ">= 1.6"

  required_providers {
    aws        = { source = "hashicorp/aws",       version = "~> 5.60" }
    kubernetes = { source = "hashicorp/kubernetes", version = "~> 2.31" }
    helm       = { source = "hashicorp/helm",       version = "~> 2.17" }
  }

  backend "s3" {
    bucket       = "kv-tf-state-ap-south-1"
    key          = "eks/apps/terraform.tfstate" # NOTE: a different key from the cluster
    region       = "ap-south-1"
    use_lockfile = true                          # S3-native locking (TF 1.10+); or dynamodb_table
    encrypt      = true
  }
}

The apps stack shares the bucket but uses a different state key from the cluster stack — that separation is the whole point of the two-stack pattern.

Step 3 — providers.tf (the exec auth) and data.tf

# data.tf
data "terraform_remote_state" "cluster" {
  backend = "s3"
  config = {
    bucket = "kv-tf-state-ap-south-1"
    key    = "eks/cluster/terraform.tfstate"
    region = "ap-south-1"
  }
}

data "aws_eks_cluster" "this" {
  name = data.terraform_remote_state.cluster.outputs.cluster_name
}
# providers.tf
provider "aws" {
  region = var.region
}

provider "kubernetes" {
  host                   = data.aws_eks_cluster.this.endpoint
  cluster_ca_certificate = base64decode(data.aws_eks_cluster.this.certificate_authority[0].data)
  exec {
    api_version = "client.authentication.k8s.io/v1beta1"
    command     = "aws"
    args        = ["eks", "get-token", "--cluster-name", data.aws_eks_cluster.this.name, "--region", var.region]
  }
}

provider "helm" {
  kubernetes {
    host                   = data.aws_eks_cluster.this.endpoint
    cluster_ca_certificate = base64decode(data.aws_eks_cluster.this.certificate_authority[0].data)
    exec {
      api_version = "client.authentication.k8s.io/v1beta1"
      command     = "aws"
      args        = ["eks", "get-token", "--cluster-name", data.aws_eks_cluster.this.name, "--region", var.region]
    }
  }
}
# variables.tf
variable "region" {
  type    = string
  default = "ap-south-1"
}

variable "base_domain" {
  type    = string
  default = "kloudvin.internal"
}

variable "gitops_repo_url" {
  type = string
}

Step 4 — platform.tf: the ALB controller with IRSA

The AWS Load Balancer Controller needs AWS permissions (to create ALBs/target groups), delivered keylessly via IRSA: an IAM role whose trust policy federates the controller’s Kubernetes service account through the cluster’s OIDC provider. Terraform builds the role and passes its ARN into the chart via templatefile.

# platform.tf
locals {
  oidc_provider_url = data.terraform_remote_state.cluster.outputs.oidc_provider_url # e.g. oidc.eks.ap-south-1.amazonaws.com/id/ABC123
  oidc_provider_arn = data.terraform_remote_state.cluster.outputs.oidc_provider_arn
}

# IAM role the controller's service account assumes (IRSA trust).
data "aws_iam_policy_document" "alb_trust" {
  statement {
    actions = ["sts:AssumeRoleWithWebIdentity"]
    effect  = "Allow"
    principals {
      type        = "Federated"
      identifiers = [local.oidc_provider_arn]
    }
    condition {
      test     = "StringEquals"
      variable = "${local.oidc_provider_url}:sub"
      values   = ["system:serviceaccount:kube-system:aws-load-balancer-controller"]
    }
    condition {
      test     = "StringEquals"
      variable = "${local.oidc_provider_url}:aud"
      values   = ["sts.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "alb" {
  name               = "eks-alb-controller"
  assume_role_policy = data.aws_iam_policy_document.alb_trust.json
}

# The official controller policy (download it, or manage inline). Attach it.
resource "aws_iam_policy" "alb" {
  name   = "AWSLoadBalancerControllerIAMPolicy"
  policy = file("${path.module}/policies/alb-controller.json")
}

resource "aws_iam_role_policy_attachment" "alb" {
  role       = aws_iam_role.alb.name
  policy_arn = aws_iam_policy.alb.arn
}

resource "helm_release" "alb_controller" {
  name       = "aws-load-balancer-controller"
  repository = "https://aws.github.io/eks-charts"
  chart      = "aws-load-balancer-controller"
  version    = "1.8.1"
  namespace  = "kube-system"

  values = [templatefile("${path.module}/values/alb.yaml.tftpl", {
    cluster_name = data.aws_eks_cluster.this.name
    role_arn     = aws_iam_role.alb.arn
    region       = var.region
    vpc_id       = data.terraform_remote_state.cluster.outputs.vpc_id
  })]
}
# values/alb.yaml.tftpl
clusterName: ${cluster_name}
region: ${region}
vpcId: ${vpc_id}
serviceAccount:
  create: true
  name: aws-load-balancer-controller
  annotations:
    eks.amazonaws.com/role-arn: ${role_arn}   # <-- IRSA binding

And External Secrets Operator, so no application secret ever enters Terraform (the operator gets its own IRSA role the same way — omitted here for length; identical trust-policy shape scoped to external-secrets:external-secrets):

resource "helm_release" "external_secrets" {
  name             = "external-secrets"
  repository       = "https://charts.external-secrets.io"
  chart            = "external-secrets"
  version          = "0.10.4"
  namespace        = "external-secrets"
  create_namespace = true
  set {
    name  = "installCRDs"
    value = "true"
  }
}

Step 5 — apps.tf and gitops.tf

apps.tf is the kubernetes_namespace + helm_release "podinfo" from earlier. gitops.tf is the helm_release "argocd" + helm_release "root_app" from the handoff section. Put them in those files as shown.

Step 6 — init, plan, apply

cd eks-apps
terraform init
Initializing the backend...
Initializing provider plugins...
- Installed hashicorp/aws v5.60.0
- Installed hashicorp/kubernetes v2.31.0
- Installed hashicorp/helm v2.17.0
Terraform has been successfully initialized!
terraform plan -var="gitops_repo_url=https://github.com/acme/eks-apps.git"
data.terraform_remote_state.cluster: Reading...
data.aws_eks_cluster.this: Read complete after 1s [name=kv-eks-dev]
...
Plan: 9 to add, 0 to change, 0 to destroy.

Note what did not happen: no Provider configuration ... cannot be determined error, because the cluster already exists and every provider input is a known value. That is the two-stack split paying off. Apply:

terraform apply -var="gitops_repo_url=https://github.com/acme/eks-apps.git" -auto-approve
helm_release.external_secrets: Creation complete after 41s [id=external-secrets]
aws_iam_role.alb: Creation complete after 3s
helm_release.alb_controller: Creation complete after 55s [id=aws-load-balancer-controller]
helm_release.podinfo: Creation complete after 22s [id=podinfo]
helm_release.argocd: Creation complete after 2m18s [id=argocd]
helm_release.root_app: Creation complete after 9s [id=root]

Apply complete! Resources: 9 added, 0 changed, 0 destroyed.

Step 7 — verify

Verify the providers reached the cluster and every layer is live:

# The sample app proves kubernetes + helm providers authenticated.
kubectl get pods -n demo
# NAME                       READY   STATUS    RESTARTS   AGE
# podinfo-7c9b8d6f5-2xk4p    1/1     Running   0          2m
# podinfo-7c9b8d6f5-9tzll    1/1     Running   0          2m

# The platform controller is up.
kubectl get deploy -n kube-system aws-load-balancer-controller
# NAME                           READY   UP-TO-DATE   AVAILABLE
# aws-load-balancer-controller   2/2     2            2

# Argo CD is up and the root App-of-Apps is registered.
kubectl get pods -n argocd
kubectl get applications -n argocd
# NAME   SYNC STATUS   HEALTH STATUS
# root   Synced        Healthy

That last command is the handoff, confirmed: the root Application exists, is Synced to Git, and Healthy. From now on, anything you commit under apps/ in the Git repo, Argo CD deploys — no Terraform. Grab the Argo CD admin password and open the UI to watch it reconcile:

kubectl -n argocd get secret argocd-initial-admin-secret \
  -o jsonpath="{.data.password}" | base64 -d && echo
kubectl -n argocd port-forward svc/argocd-server 8080:443
# open https://localhost:8080  (user: admin)

Step 8 — destroy & clean up

⚠️ Destroy the apps stack first, while the cluster is still up to receive the delete calls — this is exactly why the split matters. Argo-managed child apps live in the cluster, so let Argo prune them (or delete the root Application) before you tear down the platform:

# Optional: let Argo prune its children first for a clean teardown.
kubectl -n argocd delete application root

cd eks-apps
terraform destroy -var="gitops_repo_url=https://github.com/acme/eks-apps.git" -auto-approve
helm_release.root_app: Destroying...
helm_release.argocd: Destroying...
helm_release.podinfo: Destroying...
helm_release.alb_controller: Destroying...
...
Destroy complete! Resources: 9 destroyed.

If you destroy the cluster stack while the apps stack still has helm_release resources, those releases error with Kubernetes cluster unreachable because the API server they target is gone — the destroy-ordering trap. Apps stack down first, then the cluster stack. Only then is nothing billing.

Variables, outputs & making it reusable

The demo is already parameterised on region, base_domain, and gitops_repo_url. To make the platform reusable across dev/staging/prod, the two levers are a small platform module and for_each over the add-ons.

The for_each-over-releases pattern turns the fleet of controllers into data. Define them once as a map and loop:

variable "platform_addons" {
  description = "Helm releases that make up the platform layer."
  type = map(object({
    repository = string
    chart      = string
    version    = string
    namespace  = string
    values     = optional(list(string), [])
  }))
  default = {
    metrics-server = {
      repository = "https://kubernetes-sigs.github.io/metrics-server/"
      chart      = "metrics-server"
      version    = "3.12.1"
      namespace  = "kube-system"
    }
    external-dns = {
      repository = "https://kubernetes-sigs.github.io/external-dns/"
      chart      = "external-dns"
      version    = "1.15.0"
      namespace  = "external-dns"
    }
  }
}

resource "helm_release" "addon" {
  for_each         = var.platform_addons
  name             = each.key
  repository       = each.value.repository
  chart            = each.value.chart
  version          = each.value.version
  namespace        = each.value.namespace
  create_namespace = true
  values           = each.value.values
}

Adding a controller is now a map entry, not a copy-pasted resource block. Wrap the whole apps stack in a module and each environment becomes a thin root that passes region, gitops_repo_url (pointing at that env’s Git path), and its platform_addons map. Expose what the next layer needs:

# outputs.tf
output "argocd_namespace"    { value = helm_release.argocd.namespace }
output "alb_controller_role" { value = aws_iam_role.alb.arn }
output "gitops_repo"         { value = var.gitops_repo_url }

You do not have to roll your own for the well-trodden pieces. The community modules are excellent and worth using for the platform layer:

Need Community module / chart When to use it
Cluster + node groups terraform-aws-modules/eks/aws The cluster stack (upstream lesson)
IRSA roles terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks Every controller’s IAM — stop hand-writing trust policies
A bundle of add-ons aws-ia/eks-blueprints-addons/aws Installs ALB controller, ExternalDNS, Karpenter, etc. with IRSA wired
Argo CD argo/argo-cd Helm chart Bootstrap (as here)
App-of-Apps argo/argocd-apps Helm chart The root Application

The iam-role-for-service-accounts-eks module is the biggest time-saver: it has a pre-baked policy for the ALB controller (and ExternalDNS, ESO, cert-manager, Karpenter, etc.), so the entire platform.tf IAM block collapses to a few lines. Roll your own only when your policy is genuinely bespoke — the production platform capstone shows the blueprint-addons approach at full scale.

Common mistakes and troubleshooting

These are the failures that actually page people on EKS provider work. Scan the table; the prose below unpacks the six nastiest.

Symptom Likely cause Fix
Provider configuration ... cannot be determined until apply Provider configured from a cluster created in the same apply Split into two stacks; apps stack reads a live cluster
Unauthorized on apply, minutes after a clean plan aws_eks_cluster_auth token (~15 min) went stale Switch to the exec plugin (aws eks get-token)
x509: certificate signed by unknown authority Forgot base64decode on certificate_authority[0].data Wrap the CA in base64decode(...)
exec plugin: invalid apiVersion "...v1alpha1" Old exec API version Use client.authentication.k8s.io/v1beta1
no matches for kind "Application" in group "argoproj.io" at plan kubernetes_manifest for a CR whose CRD isn’t installed yet Install via a chart / argocd-apps; or use kubectl_manifest (apply-time)
dial tcp ...: connect: connection refused during plan kubernetes_manifest doing a plan-time dry-run against an unreachable cluster Ensure the cluster pre-exists (two stacks); avoid kubernetes_manifest for greenfield
DB password visible in terraform.tfstate kubernetes_secret / set_sensitive writes secrets to state Use External Secrets Operator + IRSA; keep secrets out of TF
helm_release stuck / context deadline exceeded / pending-install Chart resources never became ready; a prior failed install left it pending atomic = true + bump timeout; helm uninstall the stuck release, re-apply
Kubernetes cluster unreachable on destroy Destroyed the cluster stack before the apps stack Destroy apps stack first, then the cluster stack
ALB controller pod AccessDenied creating a load balancer IRSA role/trust wrong (sub/aud) or policy missing Fix the trust sub to system:serviceaccount:<ns>:<sa>; attach the policy
Error: Kubernetes cluster unreachable: the server has asked for the client to provide credentials aws CLI not on the runner, or wrong AWS profile in exec Install AWS CLI v2 on the runner; set exec.env / AWS_PROFILE
Argo CD Application stuck OutOfSync / ComparisonError Bad repoURL/path, or repo not reachable from the cluster Fix the source; add repo creds; check argocd-repo-server logs

Provider-unknown-at-plan is the defining failure of this topic, and its fix is architectural, not a flag. If your provider block references aws_eks_cluster.this.endpoint and that cluster is in the same configuration, a clean apply has no value for endpoint at plan time and Terraform refuses. depends_on and -target are band-aids that do not address the core issue that provider configs cannot consume unknowns. The only durable answer is two stacks: the apps stack reads an already-applied cluster, so every provider input is known. Internalise it as a rule — never configure the kubernetes/helm providers from a cluster resource in the same root module.

Token-expiry drift is subtle because everything looks fine until it doesn’t. With aws_eks_cluster_auth the token is computed at plan and stored, valid ~15 minutes — so in a pipeline with an approval gate between plan and apply, or on a busy runner, it dies and apply fails Unauthorized intermittently, the worst kind of bug. The exec plugin eliminates it entirely: no token in state, none to expire.

CRD-at-plan-time bites when you apply a custom resource (an Argo CD Application, a cert-manager ClusterIssuer, an ESO ExternalSecret) with kubernetes_manifest before its CRD exists. Because kubernetes_manifest fetches the object’s schema from the live API at plan, and the CRD is not there yet, the plan fails no matches for kind. Three escapes: install the CRD-bearing chart and the CR in separate applies; use a chart that bundles both (like argocd-apps); or use the kubectl_manifest resource, which applies raw YAML at apply time with no plan-time schema fetch. The demo uses the argocd-apps chart precisely to sidestep this for the root Application.

Secret-in-state is a security incident waiting to happen. terraform.tfstate stores kubernetes_secret data and set_sensitive values in base64 — trivially decodable, not encrypted. Anyone with read access to the state backend can read every secret. The fix is to keep secrets out of Terraform entirely: install External Secrets Operator, grant it an IRSA role to read AWS Secrets Manager, and let ExternalSecret manifests (delivered by Git) sync values into the cluster. Terraform provisions the capability and never sees the value. If you must put a secret through Terraform, encrypt it with SOPS so only ciphertext is in git and state.

Helm release stuck means the chart’s resources never became Ready within the timeout, or a prior failed install left the release in pending-install/pending-upgrade, and Terraform eventually errors context deadline exceeded. Two habits fix most of it: set atomic = true so a failed install rolls back instead of leaving a half-installed pending release, and raise timeout for slow charts (Argo CD, Istio). To clear an inherited stuck release, helm -n <ns> uninstall <name>, then re-apply.

Destroy ordering inverts the create order and is the reason the two-stack split matters as much on the way down as on the way up. helm_release resources need to reach the cluster’s API to uninstall; if the cluster is already gone, they error Kubernetes cluster unreachable and the destroy hangs with orphaned finalizers. Always tear down the apps stack (which talks to the cluster) before the cluster stack (which removes it). Within the apps stack, let Argo CD prune its children — or delete the root Application — before destroying, so Argo-managed workloads with finalizers don’t block the teardown.

Cost, cleanup & production notes

The apps stack itself is nearly free — Helm releases and IAM roles cost nothing. What costs money is the cluster this all runs on and anything the controllers create (notably an ALB when you deploy an Ingress). Rough Mumbai (ap-south-1) figures to make the “destroy it” case concrete:

Component Rate (approx) Left running ~24h
EKS control plane ~$0.10/hr ~$2.40 (~₹200)
2 × t3.large nodes ~$0.09/hr each ~$4.30 (~₹360)
ALB created by the controller (per Ingress) ~$0.025/hr + LCU ~$0.60+ (~₹50+)
NAT gateway (from the cluster stack) ~$0.05/hr + data ~$1.20 (~₹100)
Helm releases, IAM roles, Argo CD $0 ₹0
Rough total ~$8–9/day (~₹700)

The single biggest lever is not leaving it running: terraform destroy the apps stack, then the cluster stack. The subtle cost trap is orphaned ALBs — if the controller created a load balancer for an Ingress and you delete the Ingress out from under it (or destroy in the wrong order), the ALB can leak and keep billing; verify in the EC2 console after teardown. Five production-hardening notes beyond the demo:

  1. State is sensitive — treat it accordingly. Even with ESO keeping app secrets out, the apps stack’s state can hold IAM ARNs and, if you slip, kubernetes_secret data. Keep state in an encrypted, access-controlled, versioned S3 bucket with locking; never local, never in git.
  2. Draw the Terraform/GitOps boundary and enforce it. Terraform owns cluster + platform; Git owns apps. Resist the pull to terraform apply an application “just this once” — it erodes the boundary and reintroduces drift Terraform won’t reconcile.
  3. Pin every chart version and every provider. Unpinned helm_release.version means a re-apply can silently jump chart major versions; unpinned providers can land helm 2 → 3 on you. Pin with explicit versions and ~>, and bump deliberately.
  4. Use IRSA (or EKS Pod Identity) for every controller — never node-role permissions. Scope each controller’s IAM to exactly its service account via the OIDC trust sub. Broad node-instance-profile permissions are a lateral-movement risk.
  5. Bootstrap Argo CD, then let it manage itself and drift-detect the platform. After bootstrap, move the platform charts’ desired state into Git so Argo reconciles them too, and run scheduled terraform plan (drift detection) on the cluster stack to catch out-of-band changes.

Cheat-sheet

Task HCL / command
kubernetes provider (exec) provider "kubernetes" { host cluster_ca_certificate exec { command="aws" args=["eks","get-token",...] } }
Decode the CA cluster_ca_certificate = base64decode(data.aws_eks_cluster.this.certificate_authority[0].data)
exec api_version api_version = "client.authentication.k8s.io/v1beta1"
helm provider 2.x provider "helm" { kubernetes { host ... exec {...} } }
helm provider 3.x provider "helm" { kubernetes = { host ... exec = {...} } }
Read the cluster (fresh) data "aws_eks_cluster" "this" { name = ... }.endpoint, .certificate_authority[0].data
Short-lived token (⚠️) data "aws_eks_cluster_auth" "this" { name = ... }.token (15 min)
Join two stacks data "terraform_remote_state" "cluster" { backend="s3" config={...} }
Install a chart resource "helm_release" "x" { repository chart version namespace values=[...] }
Templated values values = [templatefile("${path.module}/values/x.yaml.tftpl", { role_arn = ... })]
Raw object resource "kubernetes_manifest" "x" { manifest = { apiVersion kind metadata spec } }
Apply CR before CRD exists use argocd-apps/bundling chart, or kubectl_manifest (apply-time)
IRSA trust condition condition { variable = "${oidc}:sub" values = ["system:serviceaccount:<ns>:<sa>"] }
Bootstrap Argo CD helm_release "argocd" { repository="https://argoproj.github.io/argo-helm" chart="argo-cd" }
Root App-of-Apps helm_release "root_app" { chart="argocd-apps" values=[yamlencode({applications={...}})] }
Continuous reconcile syncPolicy = { automated = { prune = true, selfHeal = true } }
Verify handoff kubectl get applications -n argocdSynced / Healthy
Destroy order apps stack first (terraform destroy), then the cluster stack

Interview and exam questions

1. Why is exec/aws eks get-token preferred over the aws_eks_cluster_auth data source for the kubernetes provider? The exec plugin mints a fresh token at connect time and never writes it to state; aws_eks_cluster_auth computes a ~15-minute token at plan time and bakes it into the plan/state, so it goes stale between plan and apply (intermittent Unauthorized) and shows as perpetual drift.

2. You forgot one function on the CA and TLS fails. Which? base64decode. certificate_authority[0].data is base64-encoded PEM; the cluster_ca_certificate argument needs it decoded, or you get x509: certificate signed by unknown authority.

3. Why can’t you create the EKS cluster and a helm_release on it in the same apply? Provider configurations are evaluated before resources exist and cannot consume unknown values; on a clean apply the cluster’s endpoint/CA are unknown at plan, so the provider config is invalid (cannot be determined until apply). Split into two stacks.

4. What is special about kubernetes_manifest at plan time? It contacts the live API server for a server-side dry-run/schema fetch during plan, so the cluster must already exist and the resource’s CRD must already be installed at plan time — otherwise no matches for kind. helm_release has neither requirement.

5. Where do secrets go when you use kubernetes_secret or set_sensitive, and what’s the fix? Into terraform.tfstate, base64-encoded (not encrypted) — readable by anyone with state access. Fix: External Secrets Operator + IRSA (values flow AWS Secrets Manager → cluster, never through Terraform), or SOPS for cipher-only-in-git.

6. Draw the Terraform vs GitOps boundary. What does each own? Terraform owns the cluster, VPC, node groups, IAM/IRSA, and bootstraps the platform controllers and Argo CD/Flux. GitOps owns application Deployments/Services/Ingress/config and reconciles them continuously from Git. Terraform = low-cadence infra; GitOps = high-cadence apps.

7. How does Terraform “hand off” to GitOps in code? It installs Argo CD with a helm_release, then creates a single root App-of-Apps Application (via the argocd-apps chart) pointing at a Git repo with syncPolicy.automated { prune, selfHeal }. Thereafter Argo CD manages every app from Git; Terraform doesn’t run for app changes.

8. Why bootstrap the root Application with the argocd-apps chart instead of kubernetes_manifest? The Application is a CR of a CRD that the Argo CD chart just installed; kubernetes_manifest needs the CRD at plan time and would fail no matches for kind on a clean apply. The argocd-apps chart templates and applies the Application at apply time (after CRDs exist), sequenced with depends_on.

9. (Terraform Associate) Why must the apps stack use a different backend key from the cluster stack? So they have separate state files and independent lifecycles/locks. Sharing a key would merge their states, defeating the two-stack split and coupling their blast radius.

10. (Terraform Associate) A helm_release is stuck at pending-install after a timeout. What happened and how do you prevent it? The chart’s resources didn’t become ready within timeout, leaving the release pending. Set atomic = true (roll back on failure) and raise timeout; clear a stuck release with helm uninstall before re-applying.

11. In what order do you destroy, and why? Apps stack first, then the cluster stack. helm_release uninstalls must reach the cluster API; if the cluster is gone first they error Kubernetes cluster unreachable. Delete the Argo root Application (to prune children) before destroying.

12. How does a platform controller get AWS permissions without static keys? IRSA: an IAM role whose trust policy federates the controller’s Kubernetes service account through the cluster’s OIDC provider (condition on <oidc>:sub = system:serviceaccount:<ns>:<sa>), and the SA annotated with eks.amazonaws.com/role-arn. The pod assumes the role via a projected token — no stored credentials.

Key takeaways

TerraformawsEKSkuberneteshelmhelm_releaseArgo CDGitOpsIRSAExternal Secretskubernetes_manifestremote-stateIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments