Terraform

Terraform Kubernetes Provider: Understanding YAML Interop and Limitations

2026-04-19
NicheeLab Editorial Team

Running Kubernetes often means reconciling an existing pile of YAML manifests with the desire to manage everything consistently from Terraform HCL. This article covers how the Terraform Kubernetes Provider relates to YAML, the real-world limits, and the decision points exams tend to focus on.

Up front: Terraform performs CRUD against the Kubernetes API; it does not replicate kubectl apply behavior — especially the field ownership and conflict resolution of server-side apply. We focus on stable, documented behavior and call out areas where provider versions matter.

Where Terraform and YAML Sit

Terraform pushes HCL-defined resources to the Kubernetes API via a provider. Plain kube YAML is applied through kubectl or other clients. Helm renders YAML templates and ultimately ships YAML (or equivalent JSON) into the Kubernetes API.

The destination is the same, but the path and the state-management philosophy differ. Terraform shines at state management and plan-driven workflows; kubectl and Helm shine at flexible, Kubernetes-native application.

  • Use typed HCL resources: no YAML needed. Get the full benefit of Terraform plan and state.
  • Ingest YAML: read it with yamldecode and pass it to a manifest-style resource (provider feature support varies — check the docs).
  • Use Helm: pass values.yaml and manage bundled distributions via Charts.

Conceptual paths to Kubernetes

HCL (Terraform)Kubernetes ProviderYAML (manifests)kubectlHCL + values.yaml (Helm)Helm Provider / ClientKubernetes API ServerHCL, YAML, and Helm — all three paths terminate at the Kubernetes API Server

Minimal Kubernetes provider configuration (using a kubeconfig)

terraform {
  required_providers {
    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = ">= 2.0"
    }
  }
}

provider "kubernetes" {
  config_path    = var.kubeconfig_path
  config_context = var.kube_context
}

variable "kubeconfig_path" { type = string }
variable "kube_context" { type = string }

Typed Resources: Benefits and Limits

Typed Kubernetes Provider resources (kubernetes_deployment_v1, kubernetes_service_v1, etc.) give you schema-level pre-validation and crisp diff visibility at plan time. They also play well with Terraform references and for_each, and exams treat them as the baseline pattern you should know.

On the other hand, not every Kubernetes API or CRD is covered with typed resources. Server-side defaulting and rewriting by admission/mutating webhooks and controllers can introduce diffs Terraform did not intend — pod-template annotations and automatic sidecar injection are notorious for showing up in every plan. The common workaround is to ignore specific fields via Terraform's lifecycle.ignore_changes.

  • Do not co-manage the same object with Terraform and kubectl/other tools — it is the leading cause of drift.
  • Annotations or fields likely to be rewritten by admission/mutating webhooks should be relaxed via ignore_changes.
  • Order-dependent resources (CRD → CR, etc.) should be controlled with depends_on or split applies.
  • Where possible, use set types or stable sorting so list ordering does not become a diff source.

Managing a Deployment in HCL while suppressing annotation diffs

resource "kubernetes_deployment_v1" "web" {
  metadata {
    name      = "web"
    namespace = "default"
    labels = { app = "web" }
    # Admissionやコントローラで変わりやすい注釈は無視
    annotations = { managed-by = "terraform" }
  }

  spec {
    replicas = 2

    selector { match_labels = { app = "web" } }

    template {
      metadata {
        labels = { app = "web" }
        annotations = { sidecar.istio.io/inject = "false" }
      }
      spec {
        container {
          name  = "nginx"
          image = "nginx:1.25"
          port { container_port = 80 }
        }
      }
    }
  }

  lifecycle {
    ignore_changes = [
      metadata[0].annotations,
      spec[0].template[0].metadata[0].annotations
    ]
  }
}

Patterns for Working Directly with YAML

When you want to apply existing YAML as-is, convert it into an HCL object with Terraform's yamldecode function and pass it to a generic manifest-style resource (for example kubernetes_manifest — availability and naming depend on the provider version). This works for CRDs and APIs without typed coverage, which makes it a realistic compromise.

A couple of caveats: when applying multi-document YAML in bulk, split first and manage each chunk with for_each keyed by something stable (kind/name/namespace). Also enforce CRD-before-CR ordering explicitly with depends_on.

  • For a single YAML file, pass yamldecode(file(path)) to materialize the object.
  • For many YAML files, collect with fileset and manage with for_each using stable keys.
  • Guarantee CRD → CR ordering with depends_on.
  • Confirm provider availability and behavior in the docs (version differences matter).

Read YAML and pass it to a generic manifest resource (single document)

locals {
  deploy = yamldecode(file("${path.module}/manifests/deploy.yaml"))
}

# プロバイダが提供する汎用マニフェスト系リソース名はバージョンで異なる場合があります
resource "kubernetes_manifest" "deploy" {
  manifest = local.deploy
}

YAML via Helm, Plus Terraform

The Helm Provider's helm_release manages a Chart distribution within Terraform's lifecycle. You can pass values.yaml as a raw string or generate it from HCL via yamlencode. If the Chart contains CRDs, design the ordering so CRDs exist before any CR is applied.

helm_release does not record individual Kubernetes objects as discrete Terraform resources — it manages at the release level. If you need fine-grained per-object diff management, split responsibilities between Helm and manifest-style resources.

  • Pass values via file("values.yaml") or yamlencode({ ... }).
  • For Charts containing CRDs, apply them before any resource that applies CRs (use depends_on, etc.).
  • Breaking changes on Chart upgrades can be hard to spot in plan output — proceed carefully.

Apply a Chart with helm_release and pass values.yaml

terraform {
  required_providers {
    helm = {
      source  = "hashicorp/helm"
      version = ">= 2.9"
    }
    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = ">= 2.0"
    }
  }
}

provider "helm" {
  kubernetes {
    config_path    = var.kubeconfig_path
    config_context = var.kube_context
  }
}

resource "helm_release" "nginx" {
  name       = "nginx"
  repository = "https://charts.bitnami.com/bitnami"
  chart      = "nginx"
  namespace  = "web"
  create_namespace = true

  values = [file("${path.module}/values.yaml")]
}

Server-Side Defaults and Drift

The Kubernetes API server and various controllers apply defaults to objects and rewrite annotations or fields. kubectl apply (particularly server-side apply) and Terraform differ in field ownership and conflict resolution, so mixing them causes drift. Standardize on one or the other; if you must combine them, draw clear lines of responsibility.

CRD/CR consistency, eventual consistency (reads right after apply may not yet reflect changes), list-order drift, and generated-field diffs are all best suppressed with Terraform tools: depends_on, time_sleep (when truly needed), ignore_changes, and stable keys on for_each.

  • Avoid multi-source management of the same object — pick Terraform or kubectl/Helm and stick with it.
  • Do not manage generated/mutated annotations or status fields (use ignore_changes, etc.).
  • Order CRD → CR explicitly. Plan for retries when reading immediately after apply.
  • Defuse list-ordering diffs by keying or sorting.

Guaranteeing CRD → CR order (CRDs via Helm, CRs via YAML)

locals {
  cr_files = fileset("${path.module}/crs", "*.yaml")
  cr_objs  = [for f in local.cr_files : yamldecode(file("${path.module}/crs/${f}"))]
  cr_map   = { for m in local.cr_objs : "${m["kind"]}/${m["metadata"]["namespace"]}/${m["metadata"]["name"]}" => m }
}

# 例: CRDを含むChart
resource "helm_release" "crds" {
  name       = "operator-crds"
  repository = var.operator_repo
  chart      = var.operator_crd_chart
  namespace  = "operators"
}

# CRをYAMLから適用(汎用マニフェスト系リソース想定)
resource "kubernetes_manifest" "cr" {
  for_each = local.cr_map
  manifest = each.value
  depends_on = [helm_release.crds]
}

variable "operator_repo" { type = string }
variable "operator_crd_chart" { type = string }

Exam and Real-World Takeaways, Compared

Associate/Pro exams frequently ask about choosing between typed HCL management, YAML ingestion, and Helm — plus the judgement calls around drift suppression. In production the same themes drive success: coexisting with CRDs and existing YAML, handling Secrets, and controlling order.

The table below summarises the trade-offs of each approach.

  • Lock in the basics: yamldecode/yamlencode, file/fileset, for_each, and depends_on.
  • Secrets can land in Terraform state in plaintext. Consider external secret managers (Vault, External Secrets, etc.) or an encrypted remote backend.
  • Do not let multiple tools mutate the same resource. Separate change responsibility.
ApproachHow YAML is handledBest-fit use caseMain limitations
Typed Kubernetes resources (HCL)Not needed (declared in HCL)Strict management of mainstream resources like Deployment/ServiceCRDs and uncovered APIs are out of scope; webhook mutation tends to produce diffs
Generic manifest apply (yamldecode + manifest-style)Apply YAML as-is (decoded into HCL)CRD/CR, APIs without typed coverage, reusing existing YAMLProvider features and version differences matter; field-level strict validation is weaker
Helm (helm_release)Injected into templates via values.yamlBulk distribute and update many objects as a single ChartState is per-release; fine-grained per-object control is weaker
Running kubectl via local-exec (for reference)kubectl apply applied directly to YAMLMinimal migrations or temporary workaroundsDisconnected from Terraform plan/state; weak reproducibility and drift handling

Snippet: split multi-document YAML and apply with for_each

locals {
  # --- 区切りで分割(改行を含む区切りを考慮)。空要素は除外
  raw  = file("${path.module}/bundle.yaml")
  docs = [for d in split("\n---\n", local.raw) : d if trimspace(d) != ""]
  objs = [for d in local.docs : yamldecode(d)]
  mp   = { for m in local.objs : "${m["kind"]}/${coalesce(try(m["metadata"]["namespace"], null), "default")}/${m["metadata"]["name"]}" => m }
}

resource "kubernetes_manifest" "multi" {
  for_each = local.mp
  manifest = each.value
}

Check Your Understanding

Associate / Pro

問題 1

You want to manage multiple CustomResources (CRs) defined in existing YAML via Terraform. CRDs are provided by a Helm Chart and must be applied before the CRs. Which pattern minimises drift and preserves plan-ability best?

  1. Apply the CRD Chart via helm_release, then apply CRs by reading them with yamldecode and using a generic manifest-style resource with for_each. Set depends_on from the CR side back to the helm_release.
  2. Run kubectl apply for everything sequentially via null_resource + local-exec and never record it in Terraform state.
  3. Force-fit the YAML into typed resources (kubernetes_deployment_v1, etc.) by converting it.
  4. Embed every CR into Helm's values.yaml and apply everything together via a single helm_release (letting the Chart handle dependencies).

正解: A

Applying CRDs first with helm_release, then managing CRs via yamldecode plus a generic manifest-style resource with depends_on for ordering, is the most plan-friendly and drift-resistant approach in Terraform. kubectl via local-exec has no state and is unsuitable. Force-fitting into typed resources is impractical. Embedding CRs into values gives weak ordering control and poor per-object diff visibility.

Frequently Asked Questions

Does Terraform behave the same as kubectl apply (server-side apply)?

No. Terraform performs CRUD against the Kubernetes API and does not replicate the field-ownership and conflict-resolution semantics of kubectl server-side apply. Some provider versions ship a similar mode, but do not assume it — check the official docs. Mixing both tools on the same object is a common source of drift.

Can Terraform handle multi-document YAML (--- separators) as-is?

Not as a single object. The usual pattern is to read the file, split on ---, decode each chunk with yamldecode, then apply them individually via for_each. Building a stable key (kind/namespace/name) keeps plans and diffs predictable.

Is it safe to manage Secrets with Terraform?

Secret values can end up in the Terraform state file. We recommend using Terraform Cloud/Enterprise or an encrypted remote backend, or combining Terraform with an external secrets manager such as Vault or External Secrets and letting Terraform handle only references and wiring.

Check what you learned with practice questions

Practice with certification-focused question sets

無料で問題を解いてみる
Author

NicheeLab Editorial Team

NicheeLab editorial team focused on data engineering and cloud certification learning. Content is structured around practical study needs and official exam domains.


Related articles
Terraform

HCL Syntax: Terraform's Configuration Language (2026)

HCL2 fundamentals for Terraform — blocks, attributes, expres...

Terraform

Terraform Authoring & Operations Pro: Complete Guide (2026)

Tactics for the Terraform Pro exam — module authoring, works...

Terraform

Terraform Providers: Plugin Management Fundamentals (2026)

Provider mechanics — required_providers, versions, mirrors, ...

Terraform

Terraform Resource Blocks: Declarative Infra Units (2026)

Resource block fundamentals — addresses, references, common ...

Terraform

Terraform Data Sources: Read-Only External Data (2026)

Data source basics — declaration, refresh behavior, dependen...

Browse all Terraform articles (102)
© 2026 NicheeLab All rights reserved.