Installs and manages the lifecycle of a single Helm chart release against a Kubernetes cluster, wrapping
hashicorp/helm~> 3.2'shelm_releaseresource.
- ⎈ Installs and manages the lifecycle of one Helm chart release in a target Kubernetes namespace.
- 🔒 Applies our secure-by-default posture unconditionally — atomic rollback,
cleanup_on_fail, a mandatory explicitnamespace, and hard separation of secret-shaped values intoset_sensitive_values. - 🧬 Wraps the entirety of
helm_release's current argument surface with deeply-typedobjectschemas — noany, no untypedmap. - 🚫 Never accepts cluster-authentication material; the calling root module owns the
helm/kubernetesprovider block.
💡 Why it matters: on AKS, a
helm_releaseapplyexecutes arbitrary chart hooks inside a live, regulated cluster. This module pushes as much correctness as possible intoterraform validate— a malformed input should fail at plan time, not surprise an operator mid-apply.
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- ⭐ Star this repository to help others discover this Terraform module.
- 🤝 Connect with me on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!
flowchart LR
AKS["Azure AKS module\noutputs: cluster endpoint"]:::neutral
KV["Key Vault / Managed Identity\nmodule outputs"]:::neutral
PROV["Caller-owned provider config\n(helm + kubernetes blocks)"]:::neutral
THIS["tf-mod-helm-release\n(helm_release.this)"]:::keystone
REL["Running Helm release\nin the target AKS namespace"]:::aks
AKS -->|"cluster endpoint + credentials"| PROV
KV -->|"secret values for set_sensitive_values"| THIS
PROV -->|"configures"| THIS
THIS -->|"installs / upgrades"| REL
classDef keystone fill:#0F1689,color:#fff
classDef aks fill:#277A9F,color:#fff
classDef neutral fill:#E5E7EB,color:#111
This provider has no sibling tf-mod-helm-* family tree above it — the diagram above shows the cross-provider seam instead: the Azure
AKS module and Key Vault/Managed Identity modules feed the caller's provider configuration and
this module's inputs, never this module's variables directly.
flowchart LR
subgraph INPUTS["Inputs"]
ID["name, namespace, chart,\nrepository, chart_version"]:::neutral
VAL["values_yaml, set_values,\nset_list_values, set_sensitive_values"]:::neutral
LC["atomic, cleanup_on_fail, wait,\ncreate_namespace, timeout,..."]:::neutral
end
THIS["helm_release.this"]:::keystone
subgraph OUTPUTS["Outputs"]
O1["id, name, namespace"]:::neutral
O2["status"]:::neutral
O3["metadata, manifest, resources\n(sensitive)"]:::neutral
end
ID -->|"identity + chart source"| THIS
VAL -->|"values injection"| THIS
LC -->|"lifecycle / safety knobs"| THIS
THIS -->|"emits"| O1
THIS -->|"emits"| O2
THIS -->|"emits"| O3
classDef keystone fill:#0F1689,color:#fff
classDef neutral fill:#E5E7EB,color:#111
Resource inventory: exactly one resource, helm_release.this — this provider exposes no
composite/child-resource shape.
| Component | Requirement |
|---|---|
| Terraform | >= 1.12.0 |
hashicorp/helm provider |
~> 3.2 (verified live against v3.2.0) |
| Provider block | None — the calling root module configures provider "helm" {} / provider "kubernetes" {}; this module never accepts cluster-connection variables |
Schema notes that bite:
set,set_list, andset_sensitiveare list-of-nested-objects in provider v3.x, assigned with=syntax (set = [ { name = "...", value = "..." } ]) — not the v2.x repeatableset {}HCL block. Confirmed per the live provider schema against v3.2.0.timeoutdefaults to 300 seconds and is a plain top-leveloptional(number, 300)argument, not a nestedtimeouts {}block — this provider has no house-wide "universal tail."versionis a reserved variable name inside Terraform module blocks; this module exposes it aschart_versioninstead.repositoryis not persisted as release metadata by Helm — aterraform importwill not populate this module'srepositoryvariable, and re-importing will not restore it.
The identity running terraform plan/terraform apply for this module needs, at minimum:
- Namespace-scoped:
get/list/watch/create/update/patch/deleteon every object kind the target chart deploys, via aRole/RoleBindingin the release'snamespace. - Namespace-scoped, release-history storage: CRUD on
Secretobjects in the release namespace (the provider's default Helm storage backend). - Cluster-scoped (flagged, not granted by default): if the chart installs CRDs or any
cluster-scoped object, the identity additionally needs a
ClusterRole/ClusterRoleBinding. This module does not grant cluster-scoped access itself and cannot detect the need for it from the caller's inputs — document per-chart. - If
create_namespace = true, the identity additionally needscreateon the cluster-scopednamespacesresource.
- Kubernetes server version: 1.14.0 or higher (confirmed via the provider's overview docs).
- Chart repository/registry reachability: the CI runner or operator workstation running
terraform plan/applymust have outbound network reachability to whateverrepositoryvalue is supplied (HTTPS index or OCI registry), or none ifchartis a local path. - CRDs: any CRD a chart's templates assume already exists must be installed ahead of
apply. - Namespace existence: with the default
create_namespace = false,namespacemust already exist (provisioned by the platform team) beforeapply.
tf-mod-helm-release/
├── providers.tf # Terraform + provider version pin, no provider block
├── variables.tf # Full helm_release argument surface, secure-by-default
├── main.tf # helm_release.this — the module's only resource
├── outputs.tf # id first, then name/namespace/status/metadata/manifest/resources
├── README.md
├── SCOPE.md
└── examples/
└── basic/
└── main.tf
provider "helm" {
kubernetes = {
config_path = "~/.kube/config"
}
}
module "nginx_ingress" {
source = "git::https://github.com/microsoftexpert/tf-mod-helm-release.git?ref=v1.0.0"
name = "nginx-ingress-controller"
namespace = "ingress"
chart = "nginx-ingress-controller"
repository = "https://charts.bitnami.com/bitnami"
set_values = [
{
name = "service.type"
value = "ClusterIP"
}
]
}The caller configures the helm/kubernetes provider block and Azure AD/AKS authentication
(kubelogin, workload identity, or az aks get-credentials) — this module never sees any of it.
Consumes
| Input | Type | Source module |
|---|---|---|
| AKS cluster API endpoint + credentials | provider configuration (caller-owned) | Azure AKS module or az aks get-credentials |
Key Vault secret values for set_sensitive_values |
variable input | Azure Key Vault module outputs |
| Managed Identity client ID (workload identity annotations) | variable input, via set_values/values_yaml |
Azure Managed Identity module outputs |
Namespace pre-existence (when create_namespace = false) |
out-of-band cluster state | Platform team's namespace-provisioning process |
Emits
| Output | Description | Sensitive |
|---|---|---|
id |
Release identity string (namespace/name) |
No |
name |
Release name as applied | No |
namespace |
Namespace the release was installed into | No |
status |
Current Helm release status | No |
metadata |
Full deployed-release metadata (chart, revision, values, notes) | Yes |
manifest |
Rendered manifest JSON (populated only if the caller enables experiments.manifest) |
Yes |
resources |
Rendered manifests JSON keyed by resource (same experiment gating) | Yes |
1 · Minimal HTTPS-repository install
module "redis" {
source = "git::https://github.com/microsoftexpert/tf-mod-helm-release.git?ref=v1.0.0"
name = "redis"
namespace = "caching"
chart = "redis"
repository = "https://charts.bitnami.com/bitnami"
}ℹ️ The empty-beyond-required call still gets the full secure-by-default posture:
atomic = true,cleanup_on_fail = true,wait = true,create_namespace = false.
2 · Local chart path
module "internal_service" {
source = "git::https://github.com/microsoftexpert/tf-mod-helm-release.git?ref=v1.0.0"
name = "internal-service"
namespace = "platform"
chart = "./charts/internal-service"
}💡 No
repositoryneeded whenchartis already a local filesystem path on the runner.
3 · Direct.tgz chart URL
module "redis_pinned_tgz" {
source = "git::https://github.com/microsoftexpert/tf-mod-helm-release.git?ref=v1.0.0"
name = "redis"
chart = "https://charts.bitnami.com/bitnami/redis-10.7.16.tgz"
namespace = "caching"
}4 · OCI registry chart source
provider "helm" {
kubernetes = {
config_path = "~/.kube/config"
}
registries = [
{
url = "oci://private.registry.casey.example"
username = "REPLACE_ME"
password = "REPLACE_ME"
}
]
}
module "internal_chart_oci" {
source = "git::https://github.com/microsoftexpert/tf-mod-helm-release.git?ref=v1.0.0"
name = "internal-chart"
namespace = "platform"
repository = "oci://private.registry.casey.example/charts"
chart = "internal-chart"
chart_version = "1.4.2"
}🔒 OCI registry credentials are a provider-block concern (
registriesonprovider "helm" {}), never a variable on this module.
5 · Raw values_yaml heredoc
module "grafana" {
source = "git::https://github.com/microsoftexpert/tf-mod-helm-release.git?ref=v1.0.0"
name = "grafana"
namespace = "observability"
chart = "grafana"
repository = "https://grafana.github.io/helm-charts"
values_yaml = [
<<-YAML
persistence:
enabled: true
size: 10Gi
service:
type: ClusterIP
YAML
]
}
⚠️ Values placed invalues_yamlappear in plan output, state, and CI logs in plaintext — never place secret material here.
6 · set_values for scalar overrides
module "nginx_ingress_set" {
source = "git::https://github.com/microsoftexpert/tf-mod-helm-release.git?ref=v1.0.0"
name = "nginx-ingress-controller"
namespace = "ingress"
chart = "nginx-ingress-controller"
repository = "https://charts.bitnami.com/bitnami"
set_values = [
{ name = "cluster.enabled", value = "true" },
{ name = "metrics.enabled", value = "true" },
{ name = "service.annotations.prometheus\\.io/port", value = "9127", type = "string" },
]
}7 · set_list_values for list-typed overrides
module "vault_agent" {
source = "git::https://github.com/microsoftexpert/tf-mod-helm-release.git?ref=v1.0.0"
name = "vault-agent"
namespace = "platform"
chart = "vault"
repository = "https://helm.releases.hashicorp.com"
set_list_values = [
{ name = "server.extraArgs", value = ["-log-level=info", "-log-format=json"] },
]
}8 · set_sensitive_values for secret-shaped material
module "app_with_db_secret" {
source = "git::https://github.com/microsoftexpert/tf-mod-helm-release.git?ref=v1.0.0"
name = "internal-app"
namespace = "platform"
chart = "./charts/internal-app"
set_sensitive_values = [
{ name = "database.password", value = "<from-key-vault>" },
]
}🔒 Attempting
set_values = [{ name = "database.password", value = "..." }]instead failsterraform validate— the secret-shaped-name check rejects it and points the caller atset_sensitive_values.
9 · Chart signature verification
module "verified_chart" {
source = "git::https://github.com/microsoftexpert/tf-mod-helm-release.git?ref=v1.0.0"
name = "verified-chart"
namespace = "platform"
chart = "verified-chart"
repository = "https://charts.example.com"
verify = true
keyring = "/etc/casey/helm/pubring.gpg"
}
⚠️ Settingverify = truewithoutkeyringfails validation at plan time — this module never allows an unverifiable verification attempt to silently pass.
10 · Namespace creation as a documented exception
module "ephemeral_preview" {
source = "git::https://github.com/microsoftexpert/tf-mod-helm-release.git?ref=v1.0.0"
name = "preview-app"
namespace = "pr-1234-preview"
chart = "./charts/preview-app"
create_namespace = true # Exception: ephemeral PR-preview namespace, not platform-provisioned.
}11 · CRD-skipping for an operator-managed CRD chart
module "cert_manager" {
source = "git::https://github.com/microsoftexpert/tf-mod-helm-release.git?ref=v1.0.0"
name = "cert-manager"
namespace = "cert-manager"
chart = "cert-manager"
repository = "https://charts.jetstack.io"
skip_crds = true # CRDs are managed by a separate, cluster-scoped apply step.
}
⚠️ Cluster-scoped CRD management outside this module requires its own RBAC — see this module's SCOPE.md "Required Kubernetes RBAC" section.
12 · upgrade_install adoption of a pre-existing release
module "adopted_release" {
source = "git::https://github.com/microsoftexpert/tf-mod-helm-release.git?ref=v1.0.0"
name = "legacy-app"
namespace = "legacy"
chart = "legacy-app"
repository = "https://charts.example.com"
chart_version = "2.3.1"
upgrade_install = true
}
⚠️ The first plan after adopting a pre-existing release this way will show as changed even when the deployed values are identical — there is no prior Terraform state to diff against. Expected, not a bug. See 🧠 Architecture Notes below.
13 · Multi-environment for_each composition
locals {
environments = {
dev = { namespace = "app-dev", chart_version = "1.2.0" }
test = { namespace = "app-test", chart_version = "1.2.0" }
prod = { namespace = "app-prod", chart_version = "1.1.5" }
}
}
module "app_per_env" {
source = "git::https://github.com/microsoftexpert/tf-mod-helm-release.git?ref=v1.0.0"
for_each = local.environments
name = "internal-app"
namespace = each.value.namespace
chart = "internal-app"
repository = "https://charts.example.com"
chart_version = each.value.chart_version
}💡
for_eachover a keyed map, nevercount— adding or removing an environment never re-indexes the others.
14 · Workload-identity-scoped Key Vault value injection
data "azurerm_key_vault_secret" "app_db_password" {
name = "app-db-password"
key_vault_id = var.key_vault_id
}
module "app_with_workload_identity" {
source = "git::https://github.com/microsoftexpert/tf-mod-helm-release.git?ref=v1.0.0"
name = "internal-app"
namespace = "platform"
chart = "./charts/internal-app"
set_values = [
{ name = "serviceAccount.annotations.azure\\.workload\\.identity/client-id", value = var.managed_identity_client_id },
]
set_sensitive_values = [
{ name = "database.password", value = data.azurerm_key_vault_secret.app_db_password.value },
]
}🔒 The Managed Identity client ID is not itself secret and is set via
set_values; the Key Vault-sourced database password must go throughset_sensitive_values.
15 · 🏗️ End-to-end composition — AKS module → provider → release
module "aks" {
source = "git::https://github.com/microsoftexpert/tf-mod-azure-aks-cluster.git?ref=v1.0.0"
#... AKS cluster inputs...
}
provider "helm" {
kubernetes = {
host = module.aks.cluster_endpoint
cluster_ca_certificate = base64decode(module.aks.cluster_ca_certificate)
exec = {
api_version = "client.authentication.k8s.io/v1beta1"
command = "kubelogin"
args = ["get-token", "--login", "workloadidentity", "--server-id", module.aks.oidc_issuer_url]
}
}
}
module "platform_ingress" {
source = "git::https://github.com/microsoftexpert/tf-mod-helm-release.git?ref=v1.0.0"
name = "nginx-ingress-controller"
namespace = "ingress"
chart = "nginx-ingress-controller"
repository = "https://charts.bitnami.com/bitnami"
set_sensitive_values = [
{ name = "controller.extraEnvVars[0].value", value = "<from-key-vault>" },
]
}🏗️ This is the mandatory end-to-end shape: an Azure AKS module's outputs feed the caller's
provider "helm" {}block (viakubelogin's workload-identity exec plugin), and this module's inputs are wired independently — this module itself never sees the AKS module's outputs directly.
Grouped summary:
| Group | Variables |
|---|---|
| Identity | name, namespace, description |
| Chart source | chart, repository, repository_ca_file, repository_cert_file, repository_key_file, repository_username, repository_password, chart_version, devel, verify, keyring |
| Values injection | values_yaml, set_values, set_list_values, set_sensitive_values |
| Lifecycle / safety | atomic, cleanup_on_fail, wait, wait_for_jobs, create_namespace, disable_openapi_validation, disable_webhooks, disable_crd_hooks, skip_crds, dependency_update, upgrade_install, take_ownership, timeout, max_history, reset_values, reuse_values, force_update, recreate_pods, render_subchart_notes, replace, pass_credentials |
Full object schemas
variable "set_values" {
type = list(object({
name = string
value = optional(string)
type = optional(string)
}))
default = []
}
variable "set_list_values" {
type = list(object({
name = string
value = list(string)
}))
default = []
}
variable "set_sensitive_values" {
type = list(object({
name = string
value = string
type = optional(string)
}))
default = []
sensitive = true
}See variables.tf for the complete set, including every scalar's description heredoc and
validation {} block.
| Output | Description | Sensitive |
|---|---|---|
id |
Release identity string (namespace/name) |
No |
name |
Release name as applied | No |
namespace |
Namespace the release was installed into | No |
status |
Current Helm release status | No |
metadata |
Full deployed-release metadata | Yes |
manifest |
Rendered manifest JSON (experiment-gated) | Yes |
resources |
Rendered manifests JSON keyed by resource (experiment-gated) | Yes |
- v2 → v3 syntax migration.
set,set_list, andset_sensitiveare list-of-nested-objects in provider v3.x, assigned via=, not the v2.x repeatableset {}HCL block. A v2-era example copied into a caller's code will failterraform validateagainst this module's~> 3.2pin. upgrade_installfirst-plan-shows-changed. Enablingupgrade_install = trueagainst a release that already exists in the cluster but isn't tracked in Terraform state produces a "changed" plan on the very first run, even with identical values — there is no prior state to diff against. Expected, not a bug.- Chart hooks execute outside Terraform's visibility.
pre-install/post-upgrade/etc. hooks run arbitrary logic inside the cluster duringapply;terraform plancannot preview hook behavior. Reinforces the house "no apply in this authoring pipeline" rule. repositoryis not persisted as release metadata. Aterraform importof an existing release will not populaterepository, and re-importing does not restore it.
| Default | value | Upstream provider default | What it prevents |
|---|---|---|---|
atomic |
true |
false |
A failed install/upgrade left half-applied |
cleanup_on_fail |
true |
false |
Orphaned resources from a failed upgrade |
wait |
true |
true |
A release reporting success before workloads are healthy |
namespace |
required, no default | defaults to "default" |
Accidental installs into the cluster's default namespace |
create_namespace |
false |
false |
Silent namespace creation masking a missing platform-provisioned namespace |
disable_openapi_validation |
false |
false |
Malformed manifests reaching the API server unchecked |
verify + keyring pairing |
keyring required when verify = true |
no pairing enforced | Unsigned/unverifiable charts silently deploying |
Secret-shaped set_values/set_list_values names |
rejected by validation {} |
not enforced | Secrets landing in plan output, state, and CI logs in plaintext |
set_sensitive_values |
entire variable sensitive = true |
set_sensitive masked at the argument level |
Secret values echoing in plan/apply output |
dependency_update |
false |
false |
Uncontrolled network calls to chart-dependency repositories |
timeout |
300 (provider default) |
300 |
Long-running operations blocking CI indefinitely if raised without review |
cd tf-mod-helm-release
terraform init -backend=false
terraform validate
terraform fmt -checkPin the module source with an immutable tag, never a branch:
git::https://github.com/microsoftexpert/tf-mod-helm-release.git?ref=v1.0.0
terraform plan/terraform apply are run by a human, from CI, after code review — never by this
authoring process.
terraform init -backend=false / validate / fmt -check prove schema and syntax correctness —
type mismatches, missing required arguments, malformed HCL, and this module's validation {}
blocks (secret-shaped name rejection, verify/keyring pairing, type enum checks). They do
not prove that a chart will actually render or install successfully against a live cluster —
that requires a real terraform plan/apply against a live AKS cluster, run by a human outside
this pipeline.
$ terraform output
id = "ingress/nginx-ingress-controller"
name = "nginx-ingress-controller"
namespace = "ingress"
status = "deployed"
metadata = <sensitive>
manifest = <sensitive>
resources = <sensitive>
| Symptom | Cause | Fix |
|---|---|---|
First plan after upgrade_install = true shows changed even though nothing was actually changed in the cluster |
No prior Terraform state exists to diff an adopted release against | Expected on the first plan only; confirm with helm get values that the deployed values match, then proceed |
terraform validate fails on a set { name =... value =... } block |
v2.x-era HCL block syntax copied from an old example, incompatible with this module's ~> 3.2 pin |
Rewrite as set_values = [{ name = "...", value = "..." }] (list-of-objects assignment) |
terraform validate fails with "set_values contains a secret-shaped name" |
A values path like database.password was passed through set_values |
Move the entry to set_sensitive_values |
terraform validate fails with "keyring must be set whenever verify = true" |
verify = true was set without keyring |
Supply a keyring path, or leave verify = false |
A terraform import'd release shows repository = null after import |
Helm does not persist repository as release metadata |
Manually set repository in the module call after import; this is a known provider limitation, not a bug in this module |
manifest/resources outputs are always null |
The caller's provider configuration did not enable experiments = { manifest = true } |
This is a provider-block, caller-owned setting — enable it there if the rendered manifest output is needed |
- hashicorp/helm provider docs
helm_releaseresource reference- This module's
SCOPE.md - Sibling module:
tf-mod-helm-template(for manifest-rendering/preview use cases) - The Azure AKS module this module is typically composed with
💙 "Infrastructure as Code should be standardized, consistent, and secure."