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.
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 helm_release resource
- Passing values: set blocks versus values files
- Ordering, and the CRD problem
- Where the provider is genuinely awkward
- How this fits the rest of the stack
- FAQ
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:
- Terraform Replace: lifecycle.replace_triggered_by and -replace
- Terraform Locals: Reuse Expressions Without Hiding Configuration
- Terraform templatefile(): Templates for Config Files and Scripts
- Services on RunxBuild
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.