Terraform Lesson 34 – Terraform with Kubernetes | Dataplexa
Section III · Lesson 34

Terraform with Kubernetes

Most teams that use Terraform for cloud infrastructure also run Kubernetes. The question is whether to manage Kubernetes resources — Namespaces, Deployments, Services, ConfigMaps — with Terraform, with Helm, with kubectl, or with a GitOps tool. This lesson covers the Kubernetes Terraform provider, when to use it, and the patterns that keep infrastructure management and application deployment cleanly separated.

This lesson covers

Terraform vs Helm vs kubectl — when to use each → The Kubernetes provider → Configuring the provider from an EKS or GKE cluster → Managing Namespaces and RBAC → ConfigMaps and Secrets → Deploying workloads → The Helm provider → Common patterns and anti-patterns

Terraform vs Helm vs kubectl — When to Use Each

This is the question every team faces. The answer is not "use one tool for everything" — it is understanding what each tool is good at and using them in their appropriate lanes.

Tool Best for Avoid for
Terraform kubernetes provider Namespaces, RBAC, StorageClasses, ClusterRoles — cluster-level config that changes rarely Frequently changing Deployments and Services — Terraform state drift is painful here
Terraform helm provider Installing cluster add-ons — cert-manager, ingress-nginx, external-dns, Prometheus Application workloads that deploy on every code push
kubectl / manifests One-off debugging, quick changes, emergency fixes Anything that needs to be tracked in Git or repeatable
GitOps (ArgoCD, Flux) Application workloads — continuous deployment tied to Git branches Cloud infrastructure resources (VPCs, databases, IAM)

The Analogy

Think of a Kubernetes cluster like a building. Terraform builds the structure — the floors (namespaces), the fire exits (RBAC), the electrical system (StorageClasses). Helm installs the furniture and appliances — the monitoring stack, the ingress controller, the certificate manager. GitOps moves people in and out — applications deploying on every code change. Each tool manages its own layer. Mixing them causes conflicts.

The Kubernetes Provider

The Kubernetes provider connects to a running cluster and manages resources inside it. The most important configuration decision is how to authenticate — the provider needs a kubeconfig or cluster endpoint credentials.

New terms:

  • kubeconfig — the configuration file Kubernetes clients use to connect to a cluster. Contains the cluster endpoint, certificate authority data, and user credentials. Located at ~/.kube/config by default. The Kubernetes provider can read this file directly.
  • cluster_ca_certificate — the certificate authority data for the cluster's TLS certificate. Used to verify the cluster's identity when connecting. Stored in the cluster resource output for EKS, GKE, and AKS.
  • exec plugin authentication — lets the Kubernetes provider call an external command to get credentials. Used with EKS (aws eks get-token) and GKE (gke-gcloud-auth-plugin) for dynamic token-based authentication without storing static credentials.
terraform {
  required_providers {
    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = "~> 2.23"
    }
    helm = {
      source  = "hashicorp/helm"
      version = "~> 2.11"
    }
  }
}

# ── Provider configuration for EKS ─────────────────────────────────────────

data "aws_eks_cluster" "main" {
  name = "my-eks-cluster"  # Name of the existing EKS cluster
}

data "aws_eks_cluster_auth" "main" {
  name = "my-eks-cluster"  # Generates a temporary token for authentication
}

provider "kubernetes" {
  host                   = data.aws_eks_cluster.main.endpoint   # Cluster API endpoint
  cluster_ca_certificate = base64decode(                         # Decode the CA cert
    data.aws_eks_cluster.main.certificate_authority[0].data
  )
  token = data.aws_eks_cluster_auth.main.token  # Temporary auth token from AWS
}

# ── Provider configuration for GKE ─────────────────────────────────────────

data "google_container_cluster" "main" {
  name     = "my-gke-cluster"
  location = "us-central1"
  project  = var.project_id
}

provider "kubernetes" {
  host                   = "https://${data.google_container_cluster.main.endpoint}"
  cluster_ca_certificate = base64decode(
    data.google_container_cluster.main.master_auth[0].cluster_ca_certificate
  )

  exec {
    api_version = "client.authentication.k8s.io/v1beta1"
    command     = "gke-gcloud-auth-plugin"  # GKE auth plugin — generates short-lived tokens
  }
}

# ── Provider configuration from local kubeconfig (local development) ─────────
provider "kubernetes" {
  config_path    = "~/.kube/config"      # Read from local kubeconfig file
  config_context = "my-cluster-context"  # Use a specific context from the kubeconfig
}

Important: provider configuration and cluster creation in the same apply

If you create an EKS or GKE cluster and configure the Kubernetes provider in the same Terraform configuration, you will hit a chicken-and-egg problem — the provider needs the cluster to exist before it can be configured, but both are in the same plan. The clean solution is to split into two separate configurations (or two separate workspaces): one that creates the cluster, one that manages resources inside it. Alternatively, use depends_on carefully with a -target two-step apply — but the split-config approach is cleaner and more maintainable.

Managing Namespaces and RBAC

Namespaces and RBAC are the primary reason to use the Kubernetes provider with Terraform. They are cluster-level infrastructure that rarely changes, benefits from version control and code review, and maps naturally to Terraform's declarative model.

# Namespaces — one per team or application domain
resource "kubernetes_namespace" "app" {
  metadata {
    name = "app-${var.environment}"

    labels = {
      environment = var.environment
      managed_by  = "terraform"
    }

    annotations = {
      "owner" = "platform-team"
    }
  }
}

resource "kubernetes_namespace" "monitoring" {
  metadata {
    name = "monitoring"
    labels = {
      managed_by = "terraform"
    }
  }
}

# ServiceAccount — a Kubernetes identity for a workload
resource "kubernetes_service_account" "app" {
  metadata {
    name      = "app-service-account"
    namespace = kubernetes_namespace.app.metadata[0].name  # Namespace reference
    annotations = {
      # For EKS IRSA — links the Kubernetes SA to an AWS IAM role
      "eks.amazonaws.com/role-arn" = aws_iam_role.app_eks.arn
    }
  }
}

# ClusterRole — permissions that apply across all namespaces
resource "kubernetes_cluster_role" "pod_reader" {
  metadata {
    name = "pod-reader"
  }

  rule {
    api_groups = [""]             # "" = core API group
    resources  = ["pods", "pods/log"]  # Resources this role can access
    verbs      = ["get", "list", "watch"]  # Allowed operations
  }
}

# ClusterRoleBinding — binds the ClusterRole to a subject
resource "kubernetes_cluster_role_binding" "pod_reader_binding" {
  metadata {
    name = "pod-reader-binding"
  }

  role_ref {
    api_group = "rbac.authorization.k8s.io"
    kind      = "ClusterRole"
    name      = kubernetes_cluster_role.pod_reader.metadata[0].name
  }

  subject {
    kind      = "ServiceAccount"
    name      = kubernetes_service_account.app.metadata[0].name
    namespace = kubernetes_namespace.app.metadata[0].name
  }
}

ConfigMaps and Kubernetes Secrets

ConfigMaps and Secrets are appropriate to manage with Terraform when their values come from infrastructure outputs — database endpoints, service URLs, ARNs. When they contain application configuration that developers manage, they belong in the application repository, not in Terraform.

# ConfigMap — inject infrastructure outputs as application configuration
resource "kubernetes_config_map" "app_config" {
  metadata {
    name      = "app-config"
    namespace = kubernetes_namespace.app.metadata[0].name
  }

  data = {
    # Values derived from infrastructure resources — this is the right use case
    DATABASE_HOST = aws_db_instance.main.address   # From RDS resource
    DATABASE_PORT = "5432"
    REDIS_HOST    = aws_elasticache_cluster.main.cache_nodes[0].address
    AWS_REGION    = var.region
  }
}

# Kubernetes Secret — for sensitive values from infrastructure
# WARNING: Kubernetes Secrets stored in Terraform state are base64-encoded plaintext
# Use the external-secrets operator or a CSI driver for production-grade secret management
resource "kubernetes_secret" "db_credentials" {
  metadata {
    name      = "db-credentials"
    namespace = kubernetes_namespace.app.metadata[0].name
  }

  # type defaults to Opaque — generic key-value secret
  data = {
    # Values from AWS Secrets Manager — fetched at Terraform apply time
    # base64 encoding happens automatically — provide the plaintext value
    username = local.db_credentials.username
    password = local.db_credentials.password
    # These values will be stored in Terraform state — ensure state is encrypted
  }
}

# Better pattern for secrets: External Secrets Operator
# The operator syncs secrets from AWS Secrets Manager into Kubernetes automatically
# Terraform provisions the ExternalSecret resource — not the actual secret value
resource "kubernetes_manifest" "external_secret" {
  manifest = {
    apiVersion = "external-secrets.io/v1beta1"
    kind       = "ExternalSecret"
    metadata = {
      name      = "app-db-credentials"
      namespace = kubernetes_namespace.app.metadata[0].name
    }
    spec = {
      refreshInterval = "1h"  # Sync from AWS Secrets Manager every hour
      secretStoreRef = {
        name = "aws-secrets-manager"
        kind = "ClusterSecretStore"
      }
      target = { name = "db-credentials" }  # Kubernetes secret name to create
      data = [{
        secretKey = "password"
        remoteRef = {
          key      = "prod/rds/master-credentials"  # AWS Secrets Manager secret name
          property = "password"
        }
      }]
    }
  }
}

The Helm Provider

The Helm provider installs Helm charts into a Kubernetes cluster from Terraform. This is the right tool for cluster add-ons — cert-manager, ingress-nginx, Prometheus, external-dns — that are infrastructure components rather than application workloads.

New terms:

  • helm_release — installs, upgrades, or removes a Helm chart. Equivalent to helm install or helm upgrade --install. The chart argument specifies the chart name and repository specifies its Helm repository URL.
  • set blocks — individual Helm values overrides, equivalent to --set key=value on the CLI. For complex values use values with a YAML string instead.
# Helm provider — uses same cluster authentication as Kubernetes provider
provider "helm" {
  kubernetes {
    host                   = data.aws_eks_cluster.main.endpoint
    cluster_ca_certificate = base64decode(
      data.aws_eks_cluster.main.certificate_authority[0].data
    )
    token = data.aws_eks_cluster_auth.main.token
  }
}

# Install cert-manager — manages TLS certificates automatically
resource "helm_release" "cert_manager" {
  name             = "cert-manager"
  repository       = "https://charts.jetstack.io"
  chart            = "cert-manager"
  version          = "v1.13.2"            # Always pin the chart version
  namespace        = "cert-manager"
  create_namespace = true                 # Create namespace if it does not exist

  set {
    name  = "installCRDs"
    value = "true"  # Install CustomResourceDefinitions alongside the chart
  }
}

# Install ingress-nginx — the ingress controller
resource "helm_release" "ingress_nginx" {
  name             = "ingress-nginx"
  repository       = "https://kubernetes.github.io/ingress-nginx"
  chart            = "ingress-nginx"
  version          = "4.8.3"
  namespace        = "ingress-nginx"
  create_namespace = true

  # Pass values as a YAML string for complex configurations
  values = [
    yamlencode({
      controller = {
        replicaCount = var.environment == "prod" ? 3 : 1
        service = {
          annotations = {
            "service.beta.kubernetes.io/aws-load-balancer-type" = "nlb"  # EKS NLB
          }
        }
      }
    })
  ]

  # Wait for all pods to be ready before marking the release as complete
  wait    = true
  timeout = 300  # 5 minutes — give the load balancer time to provision
}

# Install Prometheus stack for monitoring
resource "helm_release" "prometheus" {
  name             = "prometheus"
  repository       = "https://prometheus-community.github.io/helm-charts"
  chart            = "kube-prometheus-stack"
  version          = "55.5.0"
  namespace        = kubernetes_namespace.monitoring.metadata[0].name

  set {
    name  = "grafana.adminPassword"
    value = local.db_credentials.grafana_password  # From Secrets Manager
  }

  set {
    name  = "prometheus.prometheusSpec.retention"
    value = "30d"  # Keep 30 days of metrics
  }
}
$ terraform apply

helm_release.cert_manager: Creating...
helm_release.cert_manager: Still creating... [10s elapsed]
helm_release.cert_manager: Still creating... [30s elapsed]
helm_release.cert_manager: Creation complete after 45s [id=cert-manager]

helm_release.ingress_nginx: Creating...
helm_release.ingress_nginx: Still creating... [1m0s elapsed]  # Waiting for NLB
helm_release.ingress_nginx: Creation complete after 2m15s [id=ingress-nginx]

kubernetes_namespace.monitoring: Creating...
kubernetes_namespace.monitoring: Creation complete after 1s

helm_release.prometheus: Creating...
helm_release.prometheus: Still creating... [30s elapsed]
helm_release.prometheus: Creation complete after 1m10s [id=prometheus]

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

# Verify Helm releases in the cluster
$ helm list --all-namespaces
NAME          NAMESPACE      STATUS    CHART
cert-manager  cert-manager   deployed  cert-manager-v1.13.2
ingress-nginx ingress-nginx  deployed  ingress-nginx-4.8.3
prometheus    monitoring     deployed  kube-prometheus-stack-55.5.0

What just happened?

  • Three cluster add-ons installed from a single terraform apply. cert-manager, ingress-nginx, and the full Prometheus monitoring stack — all managed as Terraform resources with version pinning, configuration values, and state tracking. Upgrading cert-manager means changing the version string and running terraform apply.
  • wait = true ensures the release is fully ready before proceeding. Without it, Terraform marks the release complete as soon as Helm accepts it — even if the pods are still starting. For add-ons that other resources depend on (cert-manager must be ready before you create Certificate resources), wait = true with a generous timeout prevents timing failures.

Common Kubernetes + Terraform Anti-Patterns

Managing application Deployments with Terraform

Kubernetes Deployments that change on every application release — new container image tag, updated replica count, changed environment variables — are a poor fit for Terraform. Every release requires a terraform apply, and the state file tracks the image tag, creating constant state drift between deployments. Use GitOps (ArgoCD, Flux) or Helm releases managed by your CD pipeline for application workloads. Keep Terraform for cluster infrastructure.

Creating the cluster and managing its contents in the same configuration

The Kubernetes provider needs the cluster to be running to configure itself. If the cluster resource and Kubernetes/Helm provider resources are in the same configuration, a terraform plan on a fresh environment fails because the provider cannot connect to a cluster that does not yet exist. Split into two configurations — one for the cluster, one for the cluster contents. The second configuration reads the cluster outputs from the first using terraform_remote_state or data sources.

Storing sensitive Kubernetes Secret values in Terraform state

The kubernetes_secret resource stores its data in Terraform state as base64-encoded plaintext. For production clusters, use the External Secrets Operator (ESO) or the Secrets Store CSI Driver to sync secrets from AWS Secrets Manager, GCP Secret Manager, or HashiCorp Vault directly into Kubernetes — without Terraform ever seeing the values. Terraform provisions the ExternalSecret resource that points at the secret, not the secret value itself.

The clean separation pattern

Terraform owns the cluster itself and cluster-level infrastructure: the EKS/GKE/AKS cluster resource, node pools, IAM roles, VPC and subnet configuration, and cluster add-ons via the Helm provider. GitOps (ArgoCD or Flux) owns application workloads: Deployments, Services, Ingresses, HorizontalPodAutoscalers. Kubernetes RBAC and Namespaces can go in either place — Terraform is the right choice when they need to align with cloud IAM (like EKS IRSA service account annotations). This separation means your infrastructure team and application teams can work independently with their preferred tools.

Practice Questions

1. Which Terraform resource type installs a Helm chart into a Kubernetes cluster?



2. What is the clean solution to the chicken-and-egg problem of creating a cluster and managing its contents in the same Terraform configuration?



3. Name two Kubernetes resource types that are appropriate to manage with Terraform and one that is not.



Quiz

1. Why is managing application Deployments with Terraform considered an anti-pattern?


2. What is the production-grade pattern for getting AWS Secrets Manager values into Kubernetes Secrets without storing them in Terraform state?


3. What is the recommended separation between Terraform and GitOps tools when managing Kubernetes?


Section III Complete

Modules, Security and Cloud Providers

You have completed all ten lessons in Section III — building reusable modules, versioning them, security best practices, secrets management, and deep dives into AWS, Azure, GCP, and Kubernetes. You can now architect and deploy production infrastructure across multiple cloud providers with Terraform.

Coming up in Section IV — Advanced Terraform and Real-World

Lessons 35–45 cover Terraform troubleshooting, scaling to large teams, CI/CD pipelines, Jenkins integration, GitOps, drift detection, upgrade strategies, testing, performance optimisation, anti-patterns, and a complete mini project.

Next up → Lesson 35: Terraform Troubleshooting