Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild

Terraform Helm Provider: Managing Charts as Infrastructure

Sean

Platform Writer

Aug 04, 2026
9 min read

The Helm provider gives Terraform a single resource type, helm_release, that installs and upgrades charts as part of your normal plan and apply cycle.

Terraform Helm Provider: Managing Charts as Infrastructure

The appeal is obvious: provision the cluster, the DNS records, the managed database, and the workloads running inside the cluster in one plan, with one state file and one dependency graph. No handoff between a Terraform run and a separate Helm step.

It mostly delivers. The place it gets awkward is provider configuration — because the Helm provider needs credentials for a cluster that may not exist yet when Terraform evaluates the configuration.

Table of contents

Configuring the provider

The provider needs to reach your cluster’s API server. In development that usually means a kubeconfig file; in CI it means credentials from whatever created the cluster.

terraform {
  required_providers {
    helm = {
      source  = "hashicorp/helm"
      version = "~> 2.12"
    }
  }
}

# Simple: read a local kubeconfig
provider "helm" {
  kubernetes {
    config_path    = "~/.kube/config"
    config_context = "my-cluster"
  }
}

For a cluster Terraform itself creates, do not point at a kubeconfig file that will not exist on the first run. Derive the credentials from the cluster resource instead, and use an exec block so the token is fetched at apply time rather than baked into state.

provider "helm" {
  kubernetes {
    host                   = module.cluster.endpoint
    cluster_ca_certificate = base64decode(module.cluster.ca_certificate)

    exec {
      api_version = "client.authentication.k8s.io/v1beta1"
      command     = "aws"
      args        = ["eks", "get-token", "--cluster-name", module.cluster.name]
    }
  }
}

The exec approach matters for more than convenience. A static token in the provider block ends up in the state file, and tokens expire — an exec block fetches a fresh one on every operation and keeps it out of state.

The helm_release resource

One resource type covers install, upgrade, and delete. Terraform diffs the desired state against what is in the cluster and reconciles.

resource "helm_release" "ingress_nginx" {
  name       = "ingress-nginx"
  repository = "https://kubernetes.github.io/ingress-nginx"
  chart      = "ingress-nginx"
  version    = "4.10.0"

  namespace        = "ingress-nginx"
  create_namespace = true

  set {
    name  = "controller.replicaCount"
    value = 2
  }

  set {
    name  = "controller.service.type"
    value = "LoadBalancer"
  }

  wait    = true
  timeout = 600
}

Always pin version. Without it Helm resolves to the latest chart at apply time, which means the same configuration produces different results on different days — the exact property infrastructure as code exists to eliminate.

wait = true blocks until the release’s resources report ready, which is what makes dependency ordering meaningful. Without it Terraform considers the release complete as soon as the manifests are accepted, and a dependent resource can run against pods that are not up.

Set timeout deliberately. The default of 300 seconds is not enough for charts that provision cloud load balancers or wait on certificate issuance, and the resulting failure looks like a chart problem rather than a timeout.

Passing values: set blocks versus values files

Three ways to configure a chart, and they suit different situations.

resource "helm_release" "app" {
  name  = "my-app"
  chart = "./charts/my-app"

  # 1. A values file, templated so Terraform data can flow in
  values = [
    templatefile("${path.module}/values/app.yaml.tftpl", {
      image_tag  = var.image_tag
      db_host    = module.database.endpoint
    })
  ]

  # 2. Individual overrides, which win over the values files
  set {
    name  = "replicaCount"
    value = var.replica_count
  }

  # 3. Sensitive values, kept out of plan output
  set_sensitive {
    name  = "database.password"
    value = var.db_password
  }
}

Use a values file for the bulk of the configuration — it stays readable and looks like every other Helm setup, so people can transfer knowledge. Use set blocks for the handful of values that come from Terraform state, such as a database endpoint that does not exist until apply time.

set_sensitive keeps the value out of plan output and console logs. It does not keep it out of the state file — Terraform state contains everything, and a state file holding secrets belongs in encrypted remote storage with restricted access.

One quirk worth knowing: set uses Helm’s own path syntax, so a literal dot or comma inside a value must be escaped with a backslash. This bites on annotations that contain domain names.

Ordering, and the CRD problem

Terraform builds its dependency graph from references between resources. Where an implicit reference exists it orders correctly; where it does not, you need depends_on.

resource "helm_release" "cert_manager" {
  name             = "cert-manager"
  repository       = "https://charts.jetstack.io"
  chart            = "cert-manager"
  version          = "v1.14.4"
  namespace        = "cert-manager"
  create_namespace = true

  set {
    name  = "installCRDs"
    value = "true"
  }

  wait = true
}

resource "helm_release" "app" {
  name  = "my-app"
  chart = "./charts/my-app"

  # The app defines a Certificate resource, whose CRD cert-manager installs.
  # Nothing in the config references cert_manager, so state it explicitly.
  depends_on = [helm_release.cert_manager]
}

The CRD case is the classic failure. Your chart defines a custom resource whose definition another chart installs. Terraform sees no dependency, runs them in parallel, and the apply fails with “no matches for kind” — then succeeds on a retry, because by then the CRD exists. Intermittent failures that pass on retry are almost always this.

There is a deeper limitation to be aware of. Terraform plans against the current state, and it cannot know what resources a chart will create until it runs. A chart that installs CRDs which a later resource references cannot be fully planned in a single run — this is a known constraint, and the usual answer is to split cluster bootstrap and application deployment into separate applies with separate state.

Where the provider is genuinely awkward

Worth knowing before you commit an entire platform to this approach.

  • Drift is invisible. Terraform tracks the release, not the Kubernetes objects. Someone editing a Deployment with kubectl produces no diff in terraform plan.
  • Diffs are opaque. A version bump shows as a change to the version attribute, not as the manifest changes it implies. You cannot see what will actually change in the cluster from the plan output.
  • Failed applies leave partial releases. Helm’s own rollback semantics and Terraform’s state can disagree, and reconciling them by hand is unpleasant.
  • Provider configuration cannot depend on resources in the same apply in the general case, which is why cluster creation and workload deployment are usually separate root modules.

The honest framing: the Helm provider is a good fit for cluster-level infrastructure that changes rarely — ingress controllers, cert-manager, monitoring stacks, storage drivers. It is a poorer fit for application deployments that ship several times a day, where a purpose-built continuous delivery tool gives you better feedback and safer rollbacks.

Many teams land on exactly that split: Terraform for the cluster and its platform components, something else for the applications running on top.

How this fits the rest of the stack

Managing Helm through Terraform is a reasonable answer to a question worth questioning: how much Kubernetes does this workload actually need. A cluster plus an ingress controller plus cert-manager plus a monitoring stack is a lot of infrastructure to maintain before the first application pod runs. RunxBuild deploys services, databases, and static sites without a cluster underneath, and the RunxBuild hosting calculator makes that comparison concrete — the services you would run, priced against the cluster and the platform charts holding it up.

Useful related references:

FAQ

Should I pin the chart version in helm_release?

Yes, always. Without an explicit version, Helm resolves to the latest chart at apply time, so identical configuration produces different results on different days — the opposite of what infrastructure as code is for.

Why does my apply fail with ‘no matches for kind’ and then succeed on retry?

A CRD ordering problem. Your chart references a custom resource whose definition another chart installs, and Terraform ran them in parallel because nothing references the other release. Add an explicit depends_on.

Does the Helm provider detect changes made with kubectl?

No. Terraform tracks the Helm release, not the individual Kubernetes objects. Manual edits to a Deployment produce no diff in terraform plan, which is a real limitation for drift detection.

How do I pass secrets to a chart safely?

Use set_sensitive, which keeps the value out of plan output and logs. It does not keep it out of state, so the state file must live in encrypted remote storage with restricted access.

Can I create a cluster and deploy charts in the same Terraform run?

It works if the provider gets credentials from the cluster resource via an exec block, but it is fragile because provider configuration is evaluated early. Most teams separate cluster bootstrap and workload deployment into different root modules.

#Terraform Helm Provider#Terraform#Helm#Kubernetes#Infrastructure as Code