This guide was written by Arthur Azrieli, CTO at MeteorOps, and first published on the MeteorOps blog.

Bottom line

You can deploy and manage Kubernetes resources with Terraform using the official hashicorp/kubernetes provider. It gives you typed resources such as kubernetes_deployment_v1, kubernetes_service_v1 and kubernetes_ingress_v1, a generic kubernetes_manifest resource for anything else (including custom resources), and it pairs with the hashicorp/helm provider for charts. Terraform plans the change, shows you a diff, and keeps everything in state next to the cloud resources it depends on.

The harder question is where Terraform should stop. Our short answer, from running this at startups and scale-ups: use Terraform for the cluster and the platform layer it needs to be useful (namespaces, RBAC, storage classes, the ingress controller, cert-manager, external-dns, and the GitOps controller itself), and hand application workloads to a GitOps tool such as Argo CD or Flux. Use Terraform for app Deployments only when the app is small, rarely changes, or is tightly coupled to cloud resources Terraform already creates.

This guide covers that decision first, then gives you copy-paste-correct HCL (HashiCorp Configuration Language) for provider authentication on EKS, GKE and AKS, a fully annotated kubernetes_deployment_v1 with the container and port blocks, Service and Ingress, YAML-from-file patterns, helm_release, and the errors you will actually hit. Everything is checked against the current provider releases: hashicorp/kubernetes v3.4.0 and hashicorp/helm v3.3.0 as of October 2026.

Should you manage Kubernetes resources with Terraform?

Kubernetes already has its own reconciliation loop. Terraform has a different one that only runs when someone runs a plan and apply. Putting both on the same object works, but you need to be deliberate about which tool owns what. This table is the boundary we use.

Layer Examples Recommended owner Why
Cloud and cluster VPC, EKS/GKE/AKS cluster, node pools, IAM roles for service accounts Terraform Cloud APIs, long-lived, needs plan and review
Cluster bootstrap Namespaces, RBAC, StorageClasses, priority classes, quotas, the Argo CD or Flux install Terraform Changes rarely, must exist before anything else, often wired to cloud IDs (IAM role ARNs, bucket names)
Platform add-ons Ingress controller, cert-manager, external-dns, Karpenter, metrics-server Terraform with helm_release, or GitOps once it is bootstrapped Either works; pick one owner per add-on and stick to it
Application workloads Deployments, Services, Ingresses, HPAs, ConfigMaps for your own services GitOps (Argo CD, Flux) with Helm or Kustomize Deployed many times a day by CI, needs fast rollbacks and continuous drift correction
One-off glue A ConfigMap holding Terraform outputs (database endpoint, queue URL) for apps to read Terraform The values only exist in Terraform; this is the cleanest hand-off

Layered stack showing which tool owns each Kubernetes layer: Terraform owns cloud and cluster and cluster bootstrap, GitOps with Argo CD or Flux owns application workloads, and platform add-ons can go either way with one owner each.
Terraform builds the foundation; GitOps owns what changes every day.

Signs Terraform is the right tool for an in-cluster object:

  • It is created once per environment and changes a few times a year.

  • It needs values that only Terraform knows (an IAM role ARN for a ServiceAccount annotation, an RDS endpoint, a VPC ID).

  • You want a new environment to come up from one command. We wrote about that goal in the one-click environment.

  • There is no GitOps controller yet, and a single small service does not justify one.

Signs it is the wrong tool:

  • The image tag changes on every merge. Running a Terraform apply per deploy is slow, needs cloud credentials in CI, and serializes deploys on the state lock.

  • Something else mutates the object (HPA, VPA, a service mesh injector, an operator). You will fight drift on every plan.

  • You need progressive delivery (canary, blue/green). That lives in tools like Argo Rollouts; see zero-downtime releases with Helm and Argo Rollouts.

  • Developers need to change their own manifests without touching infrastructure state.

Terraform Kubernetes provider setup and authentication

Pin both providers. The hashicorp/kubernetes provider v3 requires Terraform v1.0 or later, and the provider functions used later in this guide need Terraform 1.8 or later.

terraform { required_version = ">= 1.8.0" required_providers { kubernetes = { source = "hashicorp/kubernetes" version = "~> 3.4" } helm = { source = "hashicorp/helm" version = "~> 3.3" } } } 

Local development: kubeconfig

The provider does not read the KUBECONFIG environment variable. Point it at a file explicitly with config_path (or KUBE_CONFIG_PATH), and pin the context so a stray kubectl config use-context never sends an apply to production.

provider "kubernetes" { config_path = "~/.kube/config" config_context = "kind-dev" } 

EKS: exec authentication

EKS tokens are short-lived. An exec block fetches a fresh token on every run, which avoids the classic "Unauthorized" error on long applies that you get when you pass a static token from aws_eks_cluster_auth. Do not mix exec with token or client certificates in the same provider block; the provider docs call that undefined behavior.

data "aws_eks_cluster" "this" { name = var.cluster_name } 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", var.cluster_name] } } 

The IAM principal that runs Terraform also needs Kubernetes permissions: an EKS access entry (or an aws-auth mapping on older clusters) bound to an appropriate access policy. Without it, authentication succeeds and every request is rejected.

GKE and AKS

# GKE: requires gke-gcloud-auth-plugin on the machine running Terraform data "google_container_cluster" "this" { name = var.cluster_name location = var.region } provider "kubernetes" { host = "https://${data.google_container_cluster.this.endpoint}" cluster_ca_certificate = base64decode(data.google_container_cluster.this.master_auth[0].cluster_ca_certificate) exec { api_version = "client.authentication.k8s.io/v1beta1" command = "gke-gcloud-auth-plugin" } } # AKS with local accounts: credentials from the cluster data source data "azurerm_kubernetes_cluster" "this" { name = var.cluster_name resource_group_name = var.resource_group } provider "kubernetes" { alias = "aks" host = data.azurerm_kubernetes_cluster.this.kube_config[0].host client_certificate = base64decode(data.azurerm_kubernetes_cluster.this.kube_config[0].client_certificate) client_key = base64decode(data.azurerm_kubernetes_cluster.this.kube_config[0].client_key) cluster_ca_certificate = base64decode(data.azurerm_kubernetes_cluster.this.kube_config[0].cluster_ca_certificate) } 

For AKS clusters with Microsoft Entra ID integration and local accounts disabled, use an exec block that calls kubelogin instead of client certificates.

The chicken-and-egg problem: cluster and resources in one root module

The most common failure with this provider is creating the cluster and the in-cluster resources in the same root module. The provider block is configured before the cluster exists, so plans fail or, worse, the provider silently falls back to a default and targets the wrong cluster. The official provider docs warn against exactly this and recommend that the cluster and the Kubernetes resources are managed with separate apply operations.

Comparison of one root module, where the Kubernetes provider is configured before the cluster exists so the plan fails or targets the wrong cluster, with two root modules: infra/cluster creates the cluster, then infra/cluster-bootstrap reads it with a data source.
Split cluster creation and in-cluster resources into separate states applied in order.

Practical options, from most to least robust:

  1. Two root modules (two states). infra/cluster creates the cluster. infra/cluster-bootstrap reads it with data "aws_eks_cluster" (or the GKE and AKS equivalents) and configures the provider from that. Terragrunt or your CI runs them in order. This is what we default to; see our Terragrunt starter for GCP for the layout.

  2. HCP Terraform Stacks with deferred changes. Stacks can defer the Kubernetes component until the cluster outputs are known, so one deployment creates both. This is a Stacks feature, not plain Terraform CLI.

  3. Targeted first apply. terraform apply -target=module.eks then a full apply. It works for a demo, but -target is meant for exceptions, and you will forget it when rebuilding an environment.

kubernetes_deployment_v1: a complete, annotated example

Use the _v1 resources. In provider v3.0.0 every unversioned resource (kubernetes_deployment, kubernetes_service, kubernetes_namespace and the rest) was marked deprecated in favor of the version-suffixed name. The schema is the same, so examples written for kubernetes_deployment work after renaming. How to migrate existing state is covered in the FAQ below.

This example is what a production-ready Deployment looks like in HCL: namespace, named container port, env from literal values, a Secret and a ConfigMap, resource requests and limits, three probes, and a rolling update strategy.

resource "kubernetes_namespace_v1" "app" { metadata { name = "checkout" labels = { "app.kubernetes.io/part-of" = "shop" } } } locals { app_labels = { "app.kubernetes.io/name" = "checkout-api" "app.kubernetes.io/instance" = "checkout-api" } } resource "kubernetes_deployment_v1" "api" { metadata { name = "checkout-api" namespace = kubernetes_namespace_v1.app.metadata[0].name labels = local.app_labels } # Default is true: Terraform waits until the rollout completes, # so a broken image fails the apply instead of passing silently. wait_for_rollout = true spec { replicas = 3 selector { match_labels = local.app_labels # must match template labels } strategy { type = "RollingUpdate" rolling_update { max_surge = "25%" max_unavailable = "0" } } template { metadata { labels = local.app_labels } spec { service_account_name = "checkout-api" container { name = "api" # required image = "ghcr.io/acme/checkout-api:1.14.2" # One port block per port. container_port is required. port { name = "http" # referenced by probes and the Service container_port = 8080 protocol = "TCP" # default; UDP also valid } port { name = "metrics" container_port = 9090 } # One env block per variable env { name = "LOG_LEVEL" value = "info" } env { name = "DATABASE_PASSWORD" value_from { secret_key_ref { name = "checkout-db" key = "password" } } } # Import every key of a ConfigMap as env vars env_from { config_map_ref { name = "checkout-config" } } resources { requests = { cpu = "250m" memory = "256Mi" } limits = { memory = "512Mi" } } startup_probe { http_get { path = "/healthz" port = "http" } period_seconds = 5 failure_threshold = 30 # up to 150s to boot } readiness_probe { http_get { path = "/ready" port = "http" } period_seconds = 10 } liveness_probe { http_get { path = "/healthz" port = "http" } period_seconds = 10 failure_threshold = 3 } } } } } } 

Notes on the parts people get wrong:

  • selector.match_labels must be a subset of template.metadata.labels, and it is immutable after creation. Changing it forces a replacement of the Deployment.

  • Probes on http_get take port as a string, so it accepts either a number (8080) or the port name ("http"). Using names means you change the port in one place.

  • Setting a memory limit without a CPU limit is deliberate: CPU limits cause throttling, memory limits protect the node. See resource requests and limits for the reasoning.

  • A liveness probe that checks dependencies (database, downstream APIs) turns a partial outage into a restart storm. Keep it shallow; our guide on liveness probes without restart loops goes deeper.

Container port block syntax reference (and other nested blocks)

The provider maps every Kubernetes list to a repeated block, and every Kubernetes map to an HCL map attribute with =. That one rule explains most syntax errors: port and env are blocks (no equals sign, repeat them), while labels, requests and limits are maps (equals sign, braces).

Side-by-side comparison: Kubernetes lists such as port and env become repeated HCL blocks with no equals sign, while Kubernetes maps such as labels, requests and limits become map attributes with an equals sign and braces.
One mapping rule explains most HCL syntax errors in this provider.

Kubernetes YAML Terraform HCL Notes
ports: - containerPort: 8080 port { container_port = 8080 } Repeat the port block per port. Optional: name, protocol, host_port, host_ip
env: - name: X value: y env { name = "X" value = "y" } Repeat per variable. Use value_from for Secret and ConfigMap keys
envFrom: - configMapRef: env_from { config_map_ref { name = ... } } Or secret_ref; optional prefix
resources: requests: cpu resources { requests = { cpu = "250m" } } requests and limits are maps of strings
livenessProbe: httpGet: liveness_probe { http_get { path = "/" port = "http" } } Same shape for readiness_probe and startup_probe; also tcp_socket, exec, grpc
volumeMounts: volume_mount { name = ... mount_path = ... } Pair with a volume block in the pod spec
imagePullSecrets: image_pull_secrets { name = ... } In the pod spec, not the container

The schema reference for every field is on the kubernetes_deployment_v1 registry page. When you need many ports or env vars from a variable, use a dynamic block:

variable "ports" { type = map(number) default = { http = 8080 metrics = 9090 } } # inside container { ... } dynamic "port" { for_each = var.ports content { name = port.key container_port = port.value } } 

Service and Ingress with kubernetes_service_v1 and kubernetes_ingress_v1

Reference the Deployment's labels and port names instead of repeating literals. target_port is a string, so it can be the container port name.

resource "kubernetes_service_v1" "api" { metadata { name = "checkout-api" namespace = kubernetes_namespace_v1.app.metadata[0].name } spec { selector = local.app_labels type = "ClusterIP" port { name = "http" port = 80 target_port = "http" # the named container port } } } resource "kubernetes_ingress_v1" "api" { metadata { name = "checkout-api" namespace = kubernetes_namespace_v1.app.metadata[0].name annotations = { "cert-manager.io/cluster-issuer" = "letsencrypt" } } spec { ingress_class_name = "nginx" rule { host = "checkout.example.com" http { path { path = "/" path_type = "Prefix" backend { service { name = kubernetes_service_v1.api.metadata[0].name port { name = "http" } } } } } } tls { hosts = ["checkout.example.com"] secret_name = "checkout-api-tls" } } } 

For a LoadBalancer Service or an Ingress that provisions a cloud load balancer, wait_for_load_balancer = true makes Terraform wait for an address, which is useful when a DNS record in the same apply depends on it.

kubernetes_manifest and deploying YAML files with Terraform

kubernetes_manifest takes any Kubernetes object as an HCL map and applies it with server-side apply. Use it for custom resources (cert-manager ClusterIssuer, Karpenter NodePool, Argo CD Application) and for kinds the typed resources do not cover. Two requirements matter in practice:

  • It needs API access at plan time. The provider asks the cluster for the resource schema during plan, so the cluster must already exist and be reachable. This is another reason to split cluster creation into its own state.

  • The CRD must exist before the plan. If the CustomResourceDefinition is installed in the same apply (for example by a helm_release of cert-manager), the plan fails with "API did not recognize GroupVersionKind from manifest (CRD may not be installed)". depends_on does not help, because the failure happens at plan time. Install CRDs in an earlier stage.

Two lanes: installing cert-manager with helm_release and a ClusterIssuer with kubernetes_manifest in one plan fails because the CRD may not be installed, and depends_on does not help; installing CRDs in stage 1 and kubernetes_manifest in stage 2 lets the plan succeed.
The failure happens at plan time, so the fix is a separate earlier stage.

A single YAML file

resource "kubernetes_manifest" "issuer" { manifest = yamldecode(file("${path.module}/manifests/cluster-issuer.yaml")) } 

Terraform's built-in yamldecode accepts exactly one YAML document; a file that contains several documents returns an error. For multi-document files, use the provider functions.

Multi-document YAML with provider functions (Terraform 1.8+)

locals { docs = provider::kubernetes::manifest_decode_multi( file("${path.module}/manifests/monitoring.yaml") ) } resource "kubernetes_manifest" "monitoring" { # Key by kind/namespace/name so reordering the file does not recreate objects for_each = { for m in local.docs : "${m.kind}/${try(m.metadata.namespace, "_")}/${m.metadata.name}" => m } manifest = each.value } 

Key for_each by a stable identity, never by list index. With count, inserting one document at the top of the file shifts every index and Terraform plans to destroy and recreate everything below it.

Comparison of inserting one YAML document at the top: with count, every list index shifts and Terraform destroys and recreates the objects; with for_each keyed by kind, namespace and name, existing objects keep their keys and only the new object is created.
Stable keys keep reordering a YAML file from touching existing objects.

A directory of YAML files

locals { files = fileset("${path.module}/manifests", "*.yaml") objects = flatten([ for f in local.files : provider::kubernetes::manifest_decode_multi(file("${path.module}/manifests/${f}")) ]) } 

Then feed local.objects into the same keyed for_each as above.

When to use the kubectl provider instead

The community kubectl provider takes raw YAML (yaml_body) and does not need the CRD schema at plan time, which is why many teams use it for CRD-heavy bootstraps. The original gavinbunney/kubectl has not released since January 2025; the actively maintained fork is alekc/kubectl.

terraform { required_providers { kubectl = { source = "alekc/kubectl" version = "~> 2.4" } } } data "kubectl_file_documents" "crds" { content = file("${path.module}/manifests/crds.yaml") } resource "kubectl_manifest" "crds" { for_each = data.kubectl_file_documents.crds.manifests yaml_body = each.value } 

The trade-off: you lose the field-level diff that kubernetes_manifest shows, and you add a third-party provider to your supply chain.

helm_release: installing charts with the Terraform Helm provider

For third-party software that ships as a Helm chart, helm_release is almost always better than translating the chart into kubernetes_* resources. Note the Helm provider v3 syntax: kubernetes in the provider and set in the release are now attributes with =, not blocks. Most examples online still show the v2 block syntax and fail on v3.

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", var.cluster_name] } } } resource "helm_release" "argocd" { name = "argocd" namespace = "argocd" create_namespace = true repository = "https://argoproj.github.io/argo-helm" chart = "argo-cd" version = "10.10.5" # always pin; unpinned installs the latest chart values = [file("${path.module}/values/argocd.yaml")] set = [ { name = "configs.params.server\\.insecure" value = "true" } ] } 

Prefer a values file over long set lists: it is reviewable, and it is the same file you would use with the Helm CLI. Installing Argo CD or Flux this way, and then letting it own the application layer, is the cleanest Terraform to GitOps hand-off we know.

Comparison: typed resources vs kubernetes_manifest vs kubectl vs Helm vs GitOps

Approach Best for Reads schema from cluster at plan CRDs Drift handling Main drawback
kubernetes_*_v1 Core objects: namespaces, RBAC, Services, small Deployments No No Diff on next plan Verbose HCL; not every kind exists
kubernetes_manifest Custom resources, any kind, YAML you already have Required Yes, if the CRD already exists Diff on next plan (server-side apply) Fails at plan when the cluster or CRD is missing
kubectl_manifest (alekc) CRD-heavy bootstraps, raw YAML No Yes, same apply Diff on next plan (less detailed) Community provider
helm_release Third-party charts (ingress, cert-manager, Argo CD) No Yes, chart installs them Release-level diff Chart internals are opaque in the plan
Argo CD or Flux Application workloads Not applicab...Source:https://dev.to/meteorops/terraform-kubernetes-provider-deployment-guide-and-examples-1jg4

Related posts