Terraform Cheat Sheet
Advanced Terraform patterns for Azure infrastructure. Covers the language deeply, common Azure-specific idioms, and real operational concerns around state and CI/CD.
Versions: Terraform 1.9+ / AzureRM provider 4.x / random 3.6+ / Kubernetes provider 2.30+. Ephemeral values require 1.10+, write-only arguments require 1.11+, Terraform Actions require 1.14+. These are called out inline where they apply.
Last reviewed: May 2026
Portability: The examples use Azure (
azurerm), but the language patterns -for_each, dynamic blocks, expressions, lifecycle, state, testing - are provider-agnostic and apply just as well toaws,
Provider & Authentication
AzureRM provider - minimal required block
The features {} block is mandatory even if empty. subscription_id is required in v4+.
terraform {
required_version = ">= 1.9"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
provider "azurerm" {
subscription_id = var.subscription_id
features {}
}Authentication options
OIDC / Workload Identity Federation ✅ Current preferred CI/CD auth pattern (as of 2026)
No secrets stored. Configure a federated credential on the service principal in Entra ID.
provider "azurerm" {
subscription_id = var.subscription_id
use_oidc = true
tenant_id = var.tenant_id
client_id = var.client_id # or ARM_CLIENT_ID env var
features {}
}Set in the pipeline:
export ARM_USE_OIDC=true
export ARM_TENANT_ID="..."
export ARM_CLIENT_ID="..."
export ARM_SUBSCRIPTION_ID="..."Managed Identity ✅ Current preferred auth for self-hosted runners (as of 2026)
provider "azurerm" {
subscription_id = var.subscription_id
use_msi = true
features {}
}Service principal with client secret ⚠️ Legacy - avoid in new projects, use OIDC or managed identity
export ARM_CLIENT_ID="..."
export ARM_CLIENT_SECRET="..."
export ARM_TENANT_ID="..."
export ARM_SUBSCRIPTION_ID="..."No client_id/client_secret in HCL - keep secrets out of source control.
Multi-subscription with provider aliases
provider "azurerm" {
alias = "hub"
subscription_id = var.hub_subscription_id
features {}
}
provider "azurerm" {
alias = "spoke"
subscription_id = var.spoke_subscription_id
features {}
}
resource "azurerm_resource_group" "spoke_rg" {
provider = azurerm.spoke
name = "rg-spoke-prod"
location = "UK South"
}Pass the alias into a module:
module "networking" {
source = "./modules/networking"
providers = {
azurerm = azurerm.spoke
}
}Useful features {} options
provider "azurerm" {
subscription_id = var.subscription_id
features {
resource_group {
prevent_deletion_if_contains_resources = true # fail destroy if RG not empty
}
key_vault {
purge_soft_delete_on_destroy = false # keep soft-deleted vault recoverable
recover_soft_deleted_key_vaults = true
}
virtual_machine {
delete_os_disk_on_deletion = true
graceful_shutdown = false
skip_shutdown_and_force_delete = false
}
}
}See also: Azure - Auth & Context for SPN creation and role assignment commands. AWS - Authentication & Credentials for named profiles and OIDC setup used with the AWS provider.
Remote State Backend
Azure Blob Storage backend
terraform {
backend "azurerm" {
resource_group_name = "rg-tfstate-prod"
storage_account_name = "sttfstateprod001"
container_name = "tfstate"
key = "myapp/prod/terraform.tfstate"
use_azuread_auth = true # use Entra ID auth, not storage key
}
}Backend values cannot use variables - pass them via -backend-config for reuse:
terraform init \
-backend-config="resource_group_name=rg-tfstate-prod" \
-backend-config="storage_account_name=sttfstateprod001" \
-backend-config="container_name=tfstate" \
-backend-config="key=myapp/prod/terraform.tfstate"override.tf - swap backend for local dev
Add to .gitignore. Terraform merges it over the main config at runtime.
terraform {
backend "local" {
path = "terraform.tfstate"
}
}Cross-stack reference with terraform_remote_state
data "terraform_remote_state" "networking" {
backend = "azurerm"
config = {
resource_group_name = "rg-tfstate-prod"
storage_account_name = "sttfstateprod001"
container_name = "tfstate"
key = "networking/prod/terraform.tfstate"
use_azuread_auth = true
}
}
# Consume an output from the networking stack
resource "azurerm_subnet" "app" {
virtual_network_name = data.terraform_remote_state.networking.outputs.vnet_name
resource_group_name = data.terraform_remote_state.networking.outputs.vnet_rg
# ...
}Variables, Locals & Outputs
Variable types and defaults
variable "environment" {
type = string
description = "Deployment environment."
default = "prod"
}
variable "instance_count" {
type = number
default = 2
}
variable "enable_monitoring" {
type = bool
default = true
}
variable "allowed_ip_ranges" {
type = list(string)
default = ["10.0.0.0/8"]
}
variable "tags" {
type = map(string)
default = {}
}Object and tuple types
variable "vm_config" {
type = object({
size = string
os_disk_type = string
data_disk_gb = optional(number, 128) # optional() with default - Terraform 1.3+
enable_backup = optional(bool, true)
})
default = {
size = "Standard_D4ds_v5"
os_disk_type = "Premium_LRS"
}
}Variable validation
variable "location" {
type = string
validation {
condition = contains(["UK South", "UK West", "East US", "West Europe"], var.location)
error_message = "Location must be one of: UK South, UK West, East US, West Europe."
}
}
variable "vm_name" {
type = string
validation {
condition = can(regex("^[a-z][a-z0-9-]{1,13}[a-z0-9]$", var.vm_name))
error_message = "VM name must be 3-15 lowercase alphanumeric characters or hyphens, start with a letter, and end with alphanumeric."
}
}
variable "subnet_cidr" {
type = string
validation {
condition = can(cidrnetmask(var.subnet_cidr))
error_message = "subnet_cidr must be a valid CIDR block."
}
}Multiple validations on one variable
variable "tags" {
type = map(string)
validation {
condition = contains(keys(var.tags), "CostCentre")
error_message = "tags must include a 'CostCentre' key."
}
validation {
condition = contains(keys(var.tags), "ManagedBy")
error_message = "tags must include a 'ManagedBy' key."
}
}Locals - derived values and DRY
locals {
# Compose a naming prefix from convention variables
prefix = "${var.short}-${var.loc}-${var.env}"
# Merge caller tags with mandatory tags
mandatory_tags = {
ManagedBy = "Terraform"
Environment = var.environment
CostCentre = var.cost_centre
}
tags = merge(var.tags, local.mandatory_tags)
# Region lookup - safe fallback if shorthand not in map
region_map = {
uks = "UK South"
ukw = "UK West"
eus = "East US"
euw = "West Europe"
aus = "Australia East"
}
location = lookup(local.region_map, var.loc, "UK South")
}Sensitive outputs
output "connection_string" {
value = azurerm_storage_account.sa.primary_connection_string
sensitive = true # redacted in plan/apply output, still stored in state
}
output "vm_public_ips" {
value = {
for k, v in azurerm_public_ip.pip : k => v.ip_address
}
}Data Sources
Common Azure data sources
# Current client credentials
data "azurerm_client_config" "current" {}
# Current subscription details
data "azurerm_subscription" "current" {}
# An existing resource group
data "azurerm_resource_group" "shared" {
name = "rg-shared-prod"
}
# An existing virtual network
data "azurerm_virtual_network" "hub" {
name = "vnet-hub-prod"
resource_group_name = "rg-networking-prod"
}
# A specific subnet
data "azurerm_subnet" "app" {
name = "snet-app-prod"
virtual_network_name = data.azurerm_virtual_network.hub.name
resource_group_name = data.azurerm_virtual_network.hub.resource_group_name
}
# Key Vault reference
data "azurerm_key_vault" "shared" {
name = "kv-shared-prod-001"
resource_group_name = "rg-shared-prod"
}
# Read a secret from Key Vault (service principal needs Secret User)
data "azurerm_key_vault_secret" "db_password" {
name = "db-password"
key_vault_id = data.azurerm_key_vault.shared.id
}
# Log Analytics workspace
data "azurerm_log_analytics_workspace" "law" {
name = "law-prod-001"
resource_group_name = "rg-monitoring-prod"
}Use the current client’s tenant and object IDs directly:
resource "azurerm_key_vault" "kv" {
tenant_id = data.azurerm_client_config.current.tenant_id
# ...
access_policy {
tenant_id = data.azurerm_client_config.current.tenant_id
object_id = data.azurerm_client_config.current.object_id
# grant the deploying identity permissions
secret_permissions = ["Get", "List", "Set", "Delete"]
}
}Resource Patterns
count - indexed copies of one resource
resource "azurerm_availability_set" "avset" {
count = var.instance_count
name = "avset-${local.prefix}-${format("%02d", count.index + 1)}"
location = local.location
resource_group_name = azurerm_resource_group.rg.name
platform_fault_domain_count = 2
platform_update_domain_count = 5
tags = local.tags
}count produces a list - reference by index: azurerm_availability_set.avset[0].id
for_each with a map - preferred for stable keys
locals {
subnets = {
app = { cidr = "10.0.1.0/24", service_endpoints = ["Microsoft.Storage"] }
db = { cidr = "10.0.2.0/24", service_endpoints = ["Microsoft.Sql"] }
mgmt = { cidr = "10.0.3.0/24", service_endpoints = [] }
}
}
resource "azurerm_subnet" "subnets" {
for_each = local.subnets
name = "snet-${each.key}-${var.env}"
resource_group_name = azurerm_resource_group.rg.name
virtual_network_name = azurerm_virtual_network.vnet.name
address_prefixes = [each.value.cidr]
service_endpoints = each.value.service_endpoints
}
# Reference by key - stable, not index-dependent
output "app_subnet_id" {
value = azurerm_subnet.subnets["app"].id
}for_each with toset() - deduplicated list input
variable "resource_group_names" {
type = list(string)
default = ["rg-app-prod", "rg-data-prod", "rg-mgmt-prod"]
}
resource "azurerm_resource_group" "rgs" {
for_each = toset(var.resource_group_names)
name = each.key
location = local.location
tags = local.tags
}setproduct - cross-product deployments
Create every combination of two lists (e.g., deploy a resource in every region × environment pair):
locals {
regions = ["UK South", "West Europe"]
environments = ["prod", "staging"]
deployments = {
for pair in setproduct(local.regions, local.environments) :
"${pair[0]}-${pair[1]}" => {
location = pair[0]
environment = pair[1]
}
}
}
resource "azurerm_resource_group" "multi" {
for_each = local.deployments
name = "rg-app-${each.value.environment}-${replace(lower(each.value.location), " ", "")}"
location = each.value.location
tags = merge(local.tags, { Environment = each.value.environment })
}Conditional for_each with regex filter
Only create resources where a map value matches a pattern:
locals {
all_configs = {
prod_web = { env = "prod", role = "web" }
prod_api = { env = "prod", role = "api" }
stage_web = { env = "staging", role = "web" }
dev_web = { env = "dev", role = "web" }
}
}
resource "azurerm_resource_group" "prod_only" {
for_each = {
for k, v in local.all_configs : k => v
if v.env == "prod"
}
name = "rg-${each.key}"
location = local.location
}Number padding in names
resource "azurerm_network_interface" "nic" {
count = 4
name = "nic-${local.prefix}-${format("%02d", count.index + 1)}"
location = local.location
resource_group_name = azurerm_resource_group.rg.name
ip_configuration {
name = "internal"
subnet_id = azurerm_subnet.subnets["app"].id
private_ip_address_allocation = "Dynamic"
}
}
# Produces: nic-app-uks-prod-01, nic-app-uks-prod-02 ...Dynamic Blocks
Network security group rules
variable "nsg_rules" {
type = list(object({
name = string
priority = number
direction = string
access = string
protocol = string
source_port_range = string
destination_port_range = string
source_address_prefix = string
destination_address_prefix = string
}))
default = [
{
name = "allow-https-inbound"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "443"
source_address_prefix = "Internet"
destination_address_prefix = "*"
},
{
name = "deny-all-inbound"
priority = 4096
direction = "Inbound"
access = "Deny"
protocol = "*"
source_port_range = "*"
destination_port_range = "*"
source_address_prefix = "*"
destination_address_prefix = "*"
}
]
}
resource "azurerm_network_security_group" "nsg" {
name = "nsg-${local.prefix}-app"
location = local.location
resource_group_name = azurerm_resource_group.rg.name
tags = local.tags
dynamic "security_rule" {
for_each = var.nsg_rules
content {
name = security_rule.value.name
priority = security_rule.value.priority
direction = security_rule.value.direction
access = security_rule.value.access
protocol = security_rule.value.protocol
source_port_range = security_rule.value.source_port_range
destination_port_range = security_rule.value.destination_port_range
source_address_prefix = security_rule.value.source_address_prefix
destination_address_prefix = security_rule.value.destination_address_prefix
}
}
}Managed identity - all three types
One resource, three mutually exclusive cases. Only one block emits per run.
variable "identity_type" {
type = string
default = "SystemAssigned"
validation {
condition = contains(["SystemAssigned", "UserAssigned", "SystemAssigned, UserAssigned"], var.identity_type)
error_message = "identity_type must be SystemAssigned, UserAssigned, or SystemAssigned, UserAssigned."
}
}
variable "identity_ids" {
type = list(string)
default = []
}
resource "azurerm_linux_virtual_machine" "vm" {
# ...
dynamic "identity" {
for_each = var.identity_type == "SystemAssigned" ? [1] : []
content {
type = "SystemAssigned"
}
}
dynamic "identity" {
for_each = var.identity_type == "UserAssigned" ? [1] : []
content {
type = "UserAssigned"
identity_ids = var.identity_ids
}
}
dynamic "identity" {
for_each = var.identity_type == "SystemAssigned, UserAssigned" ? [1] : []
content {
type = "SystemAssigned, UserAssigned"
identity_ids = var.identity_ids
}
}
}Diagnostic settings - conditional log categories
locals {
diag_log_categories = ["AuditEvent", "AzurePolicyEvaluationDetails"]
diag_metric_categories = ["AllMetrics"]
}
resource "azurerm_monitor_diagnostic_setting" "diag" {
name = "diag-${local.prefix}"
target_resource_id = azurerm_key_vault.kv.id
log_analytics_workspace_id = data.azurerm_log_analytics_workspace.law.id
dynamic "enabled_log" {
for_each = local.diag_log_categories
content {
category = enabled_log.value
}
}
dynamic "metric" {
for_each = local.diag_metric_categories
content {
category = metric.value
}
}
}Optional subnet delegation
variable "subnet_delegations" {
type = list(object({
name = string
service = string
actions = list(string)
}))
default = []
}
resource "azurerm_subnet" "snet" {
name = "snet-app-prod"
resource_group_name = azurerm_resource_group.rg.name
virtual_network_name = azurerm_virtual_network.vnet.name
address_prefixes = ["10.0.1.0/24"]
dynamic "delegation" {
for_each = var.subnet_delegations
content {
name = delegation.value.name
service_delegation {
name = delegation.value.service
actions = delegation.value.actions
}
}
}
}Expressions & Functions
for expressions - reshaping collections
locals {
# List → list (transform)
upper_names = [for name in var.names : upper(name)]
# List → list (filter)
prod_names = [for name in var.names : name if startswith(name, "prod-")]
# List → map (index as key)
indexed = { for i, name in var.names : tostring(i) => name }
# Map → map (transform values)
env_upper = { for k, v in var.environments : k => upper(v) }
# Map → list of objects
env_list = [for k, v in var.environments : { name = k, value = v }]
# Map → filtered map
prod_envs = { for k, v in var.environments : k => v if v == "prod" }
# Flatten nested: list of objects → flat list of IDs
all_subnet_ids = flatten([
for vnet in var.vnets : [
for subnet in vnet.subnets : subnet.id
]
])
}try() - safe attribute access
Use when a value might be null or a nested attribute might not exist:
locals {
# Returns the principal_id, or empty string if identity is not set
principal_id = try(azurerm_linux_virtual_machine.vm.identity[0].principal_id, "")
# Safe lookup in a map that may not have the key
tier = try(local.tier_map[var.environment], "Standard")
}can() - validate that an expression succeeds
variable "cidr" {
type = string
validation {
condition = can(cidrnetmask(var.cidr))
error_message = "Must be a valid CIDR block."
}
}one() - extract exactly one item or null
locals {
# Returns the single identity's principal_id, or null if no identity
system_principal_id = one(azurerm_linux_virtual_machine.vm.identity[*].principal_id)
}Collection manipulation
locals {
# merge - later maps win on key conflicts
all_tags = merge(var.default_tags, var.extra_tags, { DeployedAt = timestamp() })
# concat - join lists
all_ips = concat(var.office_ips, var.vpn_ips, ["10.0.0.0/8"])
# flatten - remove nesting
flat_ids = flatten([azurerm_subnet.subnets[*].id])
# distinct - deduplicate
unique_locations = distinct(var.locations)
# keys / values - extract from map
subnet_names = keys(local.subnets)
subnet_cidrs = values(local.subnets)
# zipmap - create map from parallel lists
name_to_id = zipmap(
[for s in azurerm_subnet.subnets : s.name],
[for s in azurerm_subnet.subnets : s.id]
)
# sort
sorted_names = sort(var.names)
}String functions
locals {
# Format with zero-padding
padded = format("%03d", 7) # "007"
# Join and split
joined = join(",", var.allowed_ips) # "10.0.0.1,10.0.0.2"
parts = split("/", "10.0.0.0/24") # ["10.0.0.0", "24"]
# Replace
slug = replace(lower(var.name), " ", "-")
# Trim
clean = trimspace(" value ")
# Starts/ends with
is_prod = startswith(var.env, "prod")
# Substr
short_name = substr(var.name, 0, 24) # truncate to 24 chars
# Contains
has_https = strcontains(var.url, "https")
# Regex
matched = can(regex("^[a-z0-9-]+$", var.name))
}IP and CIDR functions
locals {
vnet_cidr = "10.0.0.0/16"
# Slice a subnet from the VNet CIDR
app_subnet = cidrsubnet(local.vnet_cidr, 8, 1) # 10.0.1.0/24
db_subnet = cidrsubnet(local.vnet_cidr, 8, 2) # 10.0.2.0/24
# Get the host count for a CIDR
host_count = pow(2, 32 - parseint(split("/", local.app_subnet)[1], 10)) - 2
# Validate an IP is in a range
is_private = cidrcontains("10.0.0.0/8", var.ip_address)
}Encode / decode
locals {
# Base64 encode an inline script (e.g., for VM custom_data)
cloud_init_b64 = base64encode(file("${path.module}/cloud-init.yaml"))
# JSON encode a complex object for use in a string field
settings_json = jsonencode({
adminUser = "azureadmin"
enableAudit = true
allowedCidrs = var.allowed_cidrs
})
# Decode JSON from a data source
parsed = jsondecode(data.http.config_api.response_body)
}Lifecycle & Dependencies
lifecycle - control resource replacement behaviour
resource "azurerm_key_vault" "kv" {
# ...
lifecycle {
# Ignore drift on access_policy (managed externally or via separate resource)
ignore_changes = [access_policy, tags["LastModified"]]
# Create new resource first, then destroy old - avoids downtime on replacement
create_before_destroy = true
# Block accidental deletion (requires explicit remove from state first)
prevent_destroy = true
# Trigger replacement when a related resource changes
replace_triggered_by = [azurerm_resource_group.rg.name]
}
}Explicit dependency
Use only when a dependency isn’t expressed through attribute references:
resource "azurerm_role_assignment" "deployer" {
# ...
depends_on = [azurerm_key_vault.kv] # wait for KV before assigning roles
}precondition and postcondition (Terraform 1.2+)
Encode invariants that must hold before or after an apply:
resource "azurerm_virtual_machine_extension" "ama" {
# ...
lifecycle {
precondition {
condition = var.vm_sku != "Standard_B1s"
error_message = "Azure Monitor Agent is not supported on Standard_B1s - choose a larger SKU."
}
postcondition {
condition = self.provision_state == "Succeeded"
error_message = "VM extension did not provision successfully."
}
}
}check blocks - ambient assertions 🧪 Terraform 1.5+
check blocks run on every plan and apply and emit warnings, not errors - the operation continues regardless. Use them for compliance drift detection, runtime health assertions, and anything you want visible in CI without being a hard gate.
Each check block can contain one optional inline data source and any number of assert blocks.
Single assertion - blob public access
check "storage_public_access" {
data "azurerm_storage_account" "sa" {
name = var.storage_account_name
resource_group_name = var.resource_group_name
}
assert {
condition = data.azurerm_storage_account.sa.allow_nested_items_to_be_public == false
error_message = "Storage account '${var.storage_account_name}' allows public blob access - this should be disabled."
}
}Multiple assertions in one block - Key Vault hardening
check "key_vault_hardening" {
data "azurerm_key_vault" "kv" {
name = azurerm_key_vault.kv.name
resource_group_name = azurerm_resource_group.rg.name
}
assert {
condition = data.azurerm_key_vault.kv.purge_protection_enabled
error_message = "Key Vault purge protection must be enabled."
}
assert {
condition = data.azurerm_key_vault.kv.soft_delete_retention_days >= 90
error_message = "Key Vault soft delete retention is ${data.azurerm_key_vault.kv.soft_delete_retention_days} days - must be at least 90."
}
assert {
condition = data.azurerm_key_vault.kv.public_network_access_enabled == false
error_message = "Key Vault must not allow public network access."
}
assert {
condition = data.azurerm_key_vault.kv.enable_rbac_authorization
error_message = "Key Vault should use RBAC authorisation, not legacy access policies."
}
}HTTP endpoint health check after apply
check "api_health" {
data "http" "health" {
url = "https://${azurerm_container_app.api.ingress[0].fqdn}/health"
retry {
attempts = 5
min_delay_ms = 5000
max_delay_ms = 30000
}
}
assert {
condition = data.http.health.status_code == 200
error_message = "API health endpoint returned HTTP ${data.http.health.status_code}, expected 200."
}
}Safe assertion using try() - attribute may not exist yet
check "nsg_flow_logs_enabled" {
assert {
condition = try(azurerm_network_watcher_flow_log.nsg.enabled, false)
error_message = "NSG flow logs are not enabled - traffic analysis will be unavailable."
}
}check vs precondition/postcondition - when to use each
check block | precondition / postcondition | |
|---|---|---|
| Failure action | Warning - plan/apply continues | Error - plan/apply aborts |
| Runs on | Every plan and apply | Before/after a specific resource |
| Can use data sources | Yes (inline data block) | No - only references in current scope |
| Best for | Compliance drift, health checks, informational guards | Hard invariants that must hold to proceed safely |
Ephemeral Values & Write-Only Arguments 🧪 Terraform 1.10+ (ephemeral) / 1.11+ (write-only)
Plan and state files persist on disk and in the remote backend - any secret stored there leaks. Terraform 1.10 introduced ephemeral resources (values that live only for the duration of one operation), and 1.11 added write-only arguments (*_wo) on resources so secrets can be sent to the provider without ever being recorded. Key Vault secrets are the canonical example.
Ephemeral resources - read a secret without persisting it
ephemeral blocks behave like data blocks but the result is discarded at the end of the operation. The value is never written to state, plan, or any output.
ephemeral "azurerm_key_vault_secret" "admin_password" {
name = "vm-admin-password"
key_vault_id = data.azurerm_key_vault.shared.id
}
# The value can be referenced anywhere a normal data source can - but only
# inside arguments that themselves accept ephemeral values (write-only args,
# provider config, other ephemeral blocks, locals marked ephemeral, etc.)Differences vs. data "azurerm_key_vault_secret":
data source | ephemeral source | |
|---|---|---|
| Stored in state | Yes (encrypted at rest only if backend supports it) | No |
| Visible in plan output | Yes (as sensitive) | No - shown as (ephemeral) |
| Refreshes on every plan | Yes | Yes |
| Usable as resource argument | Anywhere | Only in write-only / ephemeral-accepting fields |
Write-only arguments - set a secret without persisting it
Write-only attributes are paired arguments: <name>_wo carries the value, <name>_wo_version is a user-controlled version number that tells Terraform when to push a new value (since it can’t compare the old value - it isn’t stored).
resource "azurerm_key_vault_secret" "db_pass" {
name = "db-password"
key_vault_id = azurerm_key_vault.kv.id
# New write-only pattern - the plaintext never lands in state
value_wo = ephemeral.azurerm_key_vault_secret.bootstrap.value
value_wo_version = 1 # bump this integer to force a rewrite
}Bumping value_wo_version (e.g. 1 → 2) is the only way Terraform knows the value has changed. Without it, the secret is written exactly once and ignored thereafter - so this also replaces the older lifecycle { ignore_changes = [value] } idiom.
Ephemeral variables and locals
Variables and outputs (in nested modules) can be marked ephemeral. Trying to assign them to a non-ephemeral context will fail validation.
variable "db_password" {
type = string
ephemeral = true # callers must pass it via -var or TF_VAR_, never via tfvars committed to git
sensitive = true
}
locals {
# Locals that reference ephemeral values are themselves ephemeral
bootstrap_password = ephemeral.azurerm_key_vault_secret.admin_password.value
}End-to-end pattern - bootstrap a VM admin password from Key Vault, never persist it
# 1. Read the bootstrap value ephemerally (not stored in state)
ephemeral "azurerm_key_vault_secret" "vm_admin" {
name = "vm-admin-password"
key_vault_id = data.azurerm_key_vault.shared.id
}
# 2. Pass it to the VM as a write-only argument
resource "azurerm_linux_virtual_machine" "vm" {
name = "vm-${local.prefix}-app-01"
resource_group_name = azurerm_resource_group.rg.name
location = local.location
size = "Standard_D2ds_v5"
admin_username = "azureadmin"
disable_password_authentication = false
admin_password_wo = ephemeral.azurerm_key_vault_secret.vm_admin.value
admin_password_wo_version = 1
network_interface_ids = [azurerm_network_interface.nic.id]
os_disk {
caching = "ReadWrite"
storage_account_type = "Premium_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts-gen2"
version = "latest"
}
}
# 3. Rotate the password later by bumping vm_admin_password_wo_version
# and updating the secret in Key Vault out-of-band (or via a separate apply).After terraform apply, terraform show will display the VM resource but the password field will be absent from state entirely - not redacted, absent. The same plan applied against a backend dump or a leaked .tfstate will not expose the credential.
Read-only / computed-only attributes - patterns to be aware of
Some attributes are marked read-only by the provider schema: they’re populated after creation and cannot be set. Common Azure examples:
azurerm_key_vault.kv.vault_uri # set by API after create
azurerm_linux_virtual_machine.vm.private_ip_address
azurerm_storage_account.sa.primary_blob_endpointIf a value is computed and sensitive (connection strings, primary keys), pair it with sensitive = true on any output that consumes it, and prefer an ephemeral data source if a downstream resource only needs to read it transiently. To keep Terraform from churning on provider-rewritten values, use lifecycle.ignore_changes:
resource "azurerm_key_vault_secret" "external_managed" {
name = "rotated-by-another-system"
key_vault_id = azurerm_key_vault.kv.id
value = "placeholder-only-used-on-first-create"
lifecycle {
ignore_changes = [value, tags["last_rotated"]]
}
}When to use which:
| Goal | Use |
|---|---|
| Read a secret only during this run, no state record | ephemeral data source |
| Write a secret to a resource without persisting plaintext | *_wo + *_wo_version |
| Tolerate provider/external mutation of an existing attribute | lifecycle.ignore_changes |
| Hide a value from plan/apply output but keep it in state | sensitive = true |
State Management
Force-unlock a stuck state
# Bash - extract lock ID from plan output and release
lock_id=$(terraform plan 2>&1 | grep -oP 'ID:\s+\K[\w-]+')
terraform force-unlock -force "$lock_id"# PowerShell
$lockId = (terraform plan 2>&1 |
Select-String -Pattern 'ID:\s+([\w-]+)' |
ForEach-Object { $_.Matches.Groups[1].Value })
terraform force-unlock -force $lockIdmoved block - rename resources without destroying them (Terraform 1.1+)
# Rename a resource (e.g., you renamed the label in code)
moved {
from = azurerm_resource_group.example
to = azurerm_resource_group.rg
}
# Move a resource into a module
moved {
from = azurerm_virtual_network.vnet
to = module.networking.azurerm_virtual_network.vnet
}
# Move from count-indexed to for_each-keyed
moved {
from = azurerm_subnet.snet[0]
to = azurerm_subnet.snet["app"]
}import block - bring existing resources under management (Terraform 1.5+)
Preferred over terraform import CLI because it’s code-reviewable and plan-visible:
import {
id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-existing-prod"
to = azurerm_resource_group.rg
}
import {
id = "/subscriptions/.../resourceGroups/rg-net/providers/Microsoft.Network/virtualNetworks/vnet-hub"
to = module.networking.azurerm_virtual_network.hub
}Generate a starter resource block from an import:
terraform plan -generate-config-out=generated.tfState surgery - CLI operations
# List all resources in state
terraform state list
# Show details of a specific resource
terraform state show azurerm_virtual_network.hub
# Remove a resource from state (without destroying it)
terraform state rm azurerm_virtual_network.old
# Move a resource to a new address in state
terraform state mv azurerm_virtual_network.vnet module.networking.azurerm_virtual_network.vnet
# Pull current state to a local file
terraform state pull > current.tfstate
# Push a modified state file (dangerous - always backup first)
terraform state push current.tfstateTargeted operations (use sparingly)
# Plan / apply only specific resources
terraform plan -target=azurerm_resource_group.rg
terraform apply -target=module.networking
# Refresh state for a specific resource without applying changes
terraform apply -refresh-only -target=azurerm_virtual_network.hubModules
Module structure
modules/
networking/
main.tf
variables.tf
outputs.tf
versions.tf
README.mdCalling a local module
module "networking" {
source = "./modules/networking"
resource_group_name = azurerm_resource_group.rg.name
location = local.location
vnet_cidr = "10.0.0.0/16"
subnets = local.subnets
tags = local.tags
}
output "vnet_id" {
value = module.networking.vnet_id
}Calling a versioned module from a registry
module "key_vault" {
source = "libre-devops/key-vault/azurerm"
version = "~> 1.2"
kv_name = "kv-${local.prefix}-001"
resource_group_name = azurerm_resource_group.rg.name
location = local.location
tenant_id = data.azurerm_client_config.current.tenant_id
tags = local.tags
}Passing a provider alias into a module
Define which alias the module should use - child modules cannot create aliases themselves.
# Root
module "spoke_rg" {
source = "./modules/resource_group"
providers = {
azurerm = azurerm.spoke
}
name = "rg-spoke-prod"
location = "UK South"
}
# modules/resource_group/versions.tf
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
}
}
}Complex output from a module
# modules/networking/outputs.tf
output "subnet_ids" {
description = "Map of subnet name to subnet ID."
value = {
for k, v in azurerm_subnet.subnets : k => v.id
}
}
# Root - consume the map
resource "azurerm_private_endpoint" "pe" {
subnet_id = module.networking.subnet_ids["private-endpoints"]
# ...
}Azure-Specific Patterns
RBAC - role assignment
# Built-in role by name
resource "azurerm_role_assignment" "storage_blob_contributor" {
scope = azurerm_storage_account.sa.id
role_definition_name = "Storage Blob Data Contributor"
principal_id = azurerm_user_assigned_identity.app.principal_id
}
# Custom role by ID, scoped to subscription
resource "azurerm_role_assignment" "custom" {
scope = data.azurerm_subscription.current.id
role_definition_id = azurerm_role_definition.custom.role_definition_resource_id
principal_id = var.principal_id
}
# Assign the deploying principal Contributor on the RG
resource "azurerm_role_assignment" "deployer_contributor" {
scope = azurerm_resource_group.rg.id
role_definition_name = "Contributor"
principal_id = data.azurerm_client_config.current.object_id
}User-assigned managed identity + role assignment
resource "azurerm_user_assigned_identity" "app" {
name = "id-${local.prefix}-app"
resource_group_name = azurerm_resource_group.rg.name
location = local.location
tags = local.tags
}
resource "azurerm_role_assignment" "kv_reader" {
scope = azurerm_key_vault.kv.id
role_definition_name = "Key Vault Secrets User"
principal_id = azurerm_user_assigned_identity.app.principal_id
}
output "identity_client_id" {
value = azurerm_user_assigned_identity.app.client_id
}Key Vault - create, configure RBAC, write secrets
resource "azurerm_key_vault" "kv" {
name = "kv-${local.prefix}-001"
resource_group_name = azurerm_resource_group.rg.name
location = local.location
tenant_id = data.azurerm_client_config.current.tenant_id
sku_name = "standard"
enable_rbac_authorization = true # use RBAC rather than access policies
purge_protection_enabled = true
soft_delete_retention_days = 90
public_network_access_enabled = false
tags = local.tags
}
# Grant the deploying identity permissions to write secrets during apply
resource "azurerm_role_assignment" "kv_admin" {
scope = azurerm_key_vault.kv.id
role_definition_name = "Key Vault Administrator"
principal_id = data.azurerm_client_config.current.object_id
depends_on = [azurerm_key_vault.kv]
}
resource "azurerm_key_vault_secret" "db_pass" {
name = "db-password"
value = var.db_password
key_vault_id = azurerm_key_vault.kv.id
depends_on = [azurerm_role_assignment.kv_admin]
lifecycle {
ignore_changes = [value] # don't overwrite after initial creation
}
}Inject a Key Vault secret into a VM’s cloud-init or extension
data "azurerm_key_vault_secret" "admin_password" {
name = "vm-admin-password"
key_vault_id = data.azurerm_key_vault.shared.id
}
resource "azurerm_linux_virtual_machine" "vm" {
admin_password = data.azurerm_key_vault_secret.admin_password.value
disable_password_authentication = false
# ...
}Virtual network with subnets - composable pattern
resource "azurerm_virtual_network" "vnet" {
name = "vnet-${local.prefix}"
resource_group_name = azurerm_resource_group.rg.name
location = local.location
address_space = var.vnet_cidr
tags = local.tags
}
locals {
subnets = {
app = {
cidr = cidrsubnet(var.vnet_cidr[0], 8, 1)
service_endpoints = ["Microsoft.Storage", "Microsoft.KeyVault"]
delegations = []
}
aks = {
cidr = cidrsubnet(var.vnet_cidr[0], 4, 1)
service_endpoints = []
delegations = [{
name = "aks-delegation"
service = "Microsoft.ContainerService/managedClusters"
actions = ["Microsoft.Network/virtualNetworks/subnets/join/action"]
}]
}
private_endpoints = {
cidr = cidrsubnet(var.vnet_cidr[0], 8, 10)
service_endpoints = []
delegations = []
}
}
}
resource "azurerm_subnet" "subnets" {
for_each = local.subnets
name = "snet-${each.key}-${var.env}"
resource_group_name = azurerm_resource_group.rg.name
virtual_network_name = azurerm_virtual_network.vnet.name
address_prefixes = [each.value.cidr]
service_endpoints = each.value.service_endpoints
dynamic "delegation" {
for_each = each.value.delegations
content {
name = delegation.value.name
service_delegation {
name = delegation.value.service
actions = delegation.value.actions
}
}
}
}Private endpoint
resource "azurerm_private_endpoint" "kv_pe" {
name = "pe-kv-${local.prefix}"
resource_group_name = azurerm_resource_group.rg.name
location = local.location
subnet_id = azurerm_subnet.subnets["private-endpoints"].id
tags = local.tags
private_service_connection {
name = "psc-kv-${local.prefix}"
private_connection_resource_id = azurerm_key_vault.kv.id
is_manual_connection = false
subresource_names = ["vault"]
}
private_dns_zone_group {
name = "dzg-kv"
private_dns_zone_ids = [azurerm_private_dns_zone.kv.id]
}
}
resource "azurerm_private_dns_zone" "kv" {
name = "privatelink.vaultcore.azure.net"
resource_group_name = azurerm_resource_group.rg.name
tags = local.tags
}
resource "azurerm_private_dns_zone_virtual_network_link" "kv" {
name = "vnetlink-kv-${local.prefix}"
resource_group_name = azurerm_resource_group.rg.name
private_dns_zone_name = azurerm_private_dns_zone.kv.name
virtual_network_id = azurerm_virtual_network.vnet.id
tags = local.tags
}Diagnostic settings - applied to every resource in a map
resource "azurerm_monitor_diagnostic_setting" "nsg_diag" {
for_each = azurerm_network_security_group.nsgs
name = "diag-${each.key}"
target_resource_id = each.value.id
log_analytics_workspace_id = data.azurerm_log_analytics_workspace.law.id
enabled_log { category = "NetworkSecurityGroupEvent" }
enabled_log { category = "NetworkSecurityGroupRuleCounter" }
}Fetch your outbound IP for dynamic firewall rules
data "http" "runner_ip" {
url = "https://ipv4.icanhazip.com"
}
locals {
runner_ip_cidr = "${chomp(data.http.runner_ip.response_body)}/32"
}
resource "azurerm_storage_account_network_rules" "sa_rules" {
storage_account_id = azurerm_storage_account.sa.id
default_action = "Deny"
ip_rules = [local.runner_ip_cidr]
}See also: Azure - Az CLI commands for the resources being managed (Key Vault, AKS, networking). PowerShell - Azure Resources for Terraform pipeline helper functions and resource provider registration.
Region Name Lookup
variable "loc" {
type = string
description = "Shorthand location - e.g. uks, euw."
default = "uks"
}
locals {
region_map = {
uks = "UK South"
ukw = "UK West"
eus = "East US"
eus2 = "East US 2"
wus = "West US"
wus2 = "West US 2"
euw = "West Europe"
eun = "North Europe"
seau = "Southeast Asia"
aus = "Australia East"
}
location = lookup(local.region_map, var.loc, "UK South")
}OS & Environment Detection
Detect runner OS for cross-platform scripts
data "external" executes a process and reads its JSON output. On Linux, printf works inline; on Windows, use a .cmd shim.
:: printf.cmd - place at repo root for Windows runners
@echo off
echo {"os": "Windows"}data "external" "os" {
working_dir = path.module
program = ["printf", "{\"os\": \"Linux\"}"]
}
locals {
os = data.external.os.result.os
is_win = local.os == "Windows"
shell = local.is_win ? "powershell" : "bash"
}Stable timestamps for tags (avoids perpetual diff)
timestamp() re-evaluates on every plan, causing an infinite drift loop. Use an external script instead - it only runs during apply.
data "external" "timestamp" {
program = data.external.os.result.os == "Linux" ? [
"${path.module}/scripts/timestamp.sh"
] : ["powershell", "${path.module}/scripts/timestamp.ps1"]
}
locals {
tags = merge(var.tags, {
ManagedBy = "Terraform"
Environment = var.environment
LastUpdated = data.external.timestamp.result["timestamp"]
})
}# scripts/timestamp.sh
#!/usr/bin/env bash
echo "{\"timestamp\": \"$(date -u '+%Y-%m-%dT%H:%M:%SZ')\"}"# scripts/timestamp.ps1
@{ timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") } | ConvertTo-Json -CompressWorkflow & CI/CD
Standard workspace flow
terraform init
terraform workspace select prod || terraform workspace new prod
terraform validate
terraform fmt -recursive -check # fail if formatting is wrong
terraform plan -out tfplan.plan
terraform show -json tfplan.plan > tfplan.json
terraform apply tfplan.planGitHub Actions - OIDC auth with AzureRM
# .github/workflows/terraform.yml
permissions:
id-token: write
contents: read
jobs:
terraform:
runs-on: ubuntu-latest
env:
ARM_USE_OIDC: "true"
ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "~1.9"
- run: terraform init
- run: terraform validate
- run: terraform plan -out tfplan.plan
- run: terraform apply -auto-approve tfplan.planFull local validation pipeline
terraform_run() {
local workspace="${1:-prod}"
local tf_version="${2:-1.9.5}"
command -v tenv &>/dev/null && { tenv tf install "$tf_version"; tenv tf use "$tf_version"; }
terraform init && \
{ terraform workspace select "$workspace" || terraform workspace new "$workspace"; } && \
terraform validate && \
terraform fmt -recursive -check && \
terraform plan -out tfplan.plan && \
terraform show -json tfplan.plan > tfplan.json && \
{ command -v tfsec &>/dev/null && tfsec . --force-all-dirs; } && \
{ command -v checkov &>/dev/null && checkov -f tfplan.json; } && \
echo "✓ All checks passed"
rm -f tfplan.plan tfplan.json
}Key CLI flags
terraform plan \
-var-file="prod.tfvars" \
-var="image_version=1.2.3" \
-parallelism=30 \
-refresh=false \
-out tfplan.plan
# -refresh=false skips state refresh (faster; use after a confirmed-clean apply)
terraform apply \
-auto-approve \
-compact-warnings \
tfplan.plan
# Destroy a single resource (use -target sparingly - it can leave state inconsistent)
terraform destroy -target=azurerm_virtual_machine.vm.tfvars file per environment
# environments/prod.tfvars
environment = "prod"
location = "UK South"
loc = "uks"
instance_count = 3
tags = {
CostCentre = "671888"
ManagedBy = "Terraform"
}terraform plan -var-file="environments/prod.tfvars" -out tfplan.planTesting
🛠️ Deeper reference - covers the native
.tftest.hclframework: unit tests with mock providers, integration tests with real infrastructure, validation tests (expect_failures), and per-resource overrides. For quick command lookup, jump to “Commands”.
Terraform’s native test framework uses .tftest.hcl files. Place them in a tests/ subdirectory or the module root. Each file can contain run blocks that either plan or apply the module, then assert on the result.
module/
main.tf
variables.tf
outputs.tf
tests/
unit.tftest.hcl # mock providers, no real infra
integration.tftest.hcl # real apply against a test subscriptionCommands
# Run every test file discovered under the current directory
terraform test
# Run a single file
terraform test -filter=tests/unit.tftest.hcl
# Show full plan/apply output for each run block
terraform test -verbose
# Pass variables to every run block in every test file
terraform test -var="environment=test" -var-file="test.tfvars"Unit tests - mock providers, no real infrastructure (Terraform 1.7+)
mock_provider intercepts all provider calls and returns synthetic values. No Azure credentials or real resources needed.
# tests/unit.tftest.hcl
mock_provider "azurerm" {
mock_resource "azurerm_resource_group" {
defaults = {
id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-test"
location = "uksouth"
}
}
mock_resource "azurerm_virtual_network" {
defaults = {
id = "/subscriptions/00000000/resourceGroups/rg-test/providers/Microsoft.Network/virtualNetworks/vnet-test"
address_space = ["10.0.0.0/16"]
}
}
mock_data "azurerm_client_config" {
defaults = {
tenant_id = "00000000-0000-0000-0000-000000000000"
subscription_id = "00000000-0000-0000-0000-000000000000"
client_id = "00000000-0000-0000-0000-000000000000"
object_id = "00000000-0000-0000-0000-000000000000"
}
}
}
variables {
environment = "test"
loc = "uks"
}
run "resource_group_name_follows_convention" {
command = plan
assert {
condition = azurerm_resource_group.rg.name == "rg-myapp-test"
error_message = "Resource group name '${azurerm_resource_group.rg.name}' does not follow the naming convention."
}
}
run "vnet_cidr_is_within_allowed_range" {
command = plan
assert {
condition = startswith(azurerm_virtual_network.vnet.address_space[0], "10.")
error_message = "VNet CIDR must use the 10.0.0.0/8 RFC 1918 range."
}
}Testing that validation rejects bad inputs
Use expect_failures to assert that a specific variable’s validation rule fires. The run block passes if (and only if) the expected validation error occurs.
# tests/validation.tftest.hcl
mock_provider "azurerm" {}
run "invalid_location_rejected" {
command = plan
variables {
location = "Mars" # not in the allowed list
}
expect_failures = [var.location]
}
run "vm_name_with_spaces_rejected" {
command = plan
variables {
vm_name = "my vm name" # spaces are not allowed
}
expect_failures = [var.vm_name]
}
run "valid_inputs_accepted" {
command = plan
variables {
location = "UK South"
vm_name = "vm-web-prod-01"
}
# No expect_failures - plan must succeed cleanly
}Integration tests - real apply against a test environment
Integration tests actually create infrastructure. Use a dedicated test subscription and clean up with destroy in CI.
# tests/integration.tftest.hcl
provider "azurerm" {
features {}
# Credentials come from ARM_* environment variables in CI
}
variables {
environment = "test"
location = "UK South"
loc = "uks"
instance_count = 1
}
# Optional: a setup module that creates shared pre-requisites (e.g., a resource group)
# and whose outputs are available to subsequent run blocks
run "setup" {
module {
source = "./tests/setup"
}
}
run "deploy_networking" {
command = apply
assert {
condition = azurerm_virtual_network.vnet.location == "uksouth"
error_message = "VNet deployed to wrong location: ${azurerm_virtual_network.vnet.location}."
}
assert {
condition = length(azurerm_subnet.subnets) == 3
error_message = "Expected 3 subnets, got ${length(azurerm_subnet.subnets)}."
}
}
run "subnets_are_non_overlapping" {
command = plan
assert {
condition = (
azurerm_subnet.subnets["app"].address_prefixes[0] !=
azurerm_subnet.subnets["db"].address_prefixes[0]
)
error_message = "App and DB subnets share the same CIDR - address ranges overlap."
}
}Overriding specific resources or data sources mid-test (Terraform 1.7+)
override_resource and override_data let you surgically replace individual lookups without mocking the whole provider - useful when most resources are real but one data source is unavailable in the test environment.
# tests/partial_override.tftest.hcl
provider "azurerm" {
features {}
}
# Override just the shared Log Analytics workspace lookup
# (it lives in a different subscription that test credentials can't reach)
override_data {
target = data.azurerm_log_analytics_workspace.law
values = {
id = "/subscriptions/00000000/resourceGroups/rg-mon/providers/Microsoft.OperationalInsights/workspaces/law-test"
workspace_id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
primary_shared_key = "dGVzdGtleQ=="
}
}
run "diagnostic_setting_references_law" {
command = plan
assert {
condition = azurerm_monitor_diagnostic_setting.diag.log_analytics_workspace_id == data.azurerm_log_analytics_workspace.law.id
error_message = "Diagnostic setting does not reference the expected Log Analytics workspace."
}
}Multiple Providers
Declaring multiple providers
All providers must appear in the required_providers block. Terraform initialises each independently.
terraform {
required_version = ">= 1.9"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
http = {
source = "hashicorp/http"
version = "~> 3.4"
}
ansible = {
source = "ansible.com/ansible"
version = "~> 1.3"
}
}
}random - unique, stable suffixes for globally-namespaced resources
Storage account names and Key Vault names must be globally unique. Use random_string so names stay consistent between plans (the value is stored in state).
resource "random_string" "suffix" {
length = 6
lower = true
numeric = true
special = false
upper = false
}
resource "azurerm_storage_account" "sa" {
name = "st${local.short}${random_string.suffix.result}"
resource_group_name = azurerm_resource_group.rg.name
location = local.location
account_tier = "Standard"
account_replication_type = "LRS"
tags = local.tags
}
resource "azurerm_key_vault" "kv" {
name = "kv-${local.prefix}-${random_string.suffix.result}"
resource_group_name = azurerm_resource_group.rg.name
location = local.location
tenant_id = data.azurerm_client_config.current.tenant_id
sku_name = "standard"
tags = local.tags
}AKS then Kubernetes/Helm - provider chaining
Spin up the cluster with azurerm, then configure workloads with kubernetes and helm in the same root module. The Kubernetes and Helm providers are initialised using outputs from the cluster resource.
terraform {
required_providers {
azurerm = { source = "hashicorp/azurerm", version = "~> 4.0" }
kubernetes = { source = "hashicorp/kubernetes", version = "~> 2.30" }
helm = { source = "hashicorp/helm", version = "~> 2.14" }
}
}
resource "azurerm_kubernetes_cluster" "aks" {
name = "aks-${local.prefix}"
resource_group_name = azurerm_resource_group.rg.name
location = local.location
dns_prefix = local.prefix
default_node_pool {
name = "system"
node_count = 2
vm_size = "Standard_D2ds_v5"
}
identity {
type = "SystemAssigned"
}
tags = local.tags
}
provider "kubernetes" {
host = azurerm_kubernetes_cluster.aks.kube_config[0].host
client_certificate = base64decode(azurerm_kubernetes_cluster.aks.kube_config[0].client_certificate)
client_key = base64decode(azurerm_kubernetes_cluster.aks.kube_config[0].client_key)
cluster_ca_certificate = base64decode(azurerm_kubernetes_cluster.aks.kube_config[0].cluster_ca_certificate)
}
provider "helm" {
kubernetes {
host = azurerm_kubernetes_cluster.aks.kube_config[0].host
client_certificate = base64decode(azurerm_kubernetes_cluster.aks.kube_config[0].client_certificate)
client_key = base64decode(azurerm_kubernetes_cluster.aks.kube_config[0].client_key)
cluster_ca_certificate = base64decode(azurerm_kubernetes_cluster.aks.kube_config[0].cluster_ca_certificate)
}
}
resource "helm_release" "nginx_ingress" {
name = "ingress-nginx"
repository = "https://kubernetes.github.io/ingress-nginx"
chart = "ingress-nginx"
namespace = "ingress-nginx"
create_namespace = true
version = "4.10.1"
set {
name = "controller.replicaCount"
value = "2"
}
depends_on = [azurerm_kubernetes_cluster.aks]
}Ansible provider - inventory management + playbook execution via actions 🧪 Terraform 1.14+ (Actions)
The ansible.com/ansible provider manages Ansible inventory state (registering hosts and groups). Playbook execution is now driven by Terraform actions (Terraform 1.14+) - a first-class imperative construct that replaces the old terraform_data + local-exec shim. Actions are declared at the top level, hooked into resource lifecycles via action_trigger, and can also be fired ad-hoc with terraform apply -invoke.
terraform {
required_version = ">= 1.14"
required_providers {
azurerm = { source = "hashicorp/azurerm", version = "~> 4.0" }
ansible = { source = "ansible/ansible", version = "~> 2.0" } # 2.x exposes actions
}
}
provider "ansible" {}
# --- Infrastructure ---
resource "azurerm_linux_virtual_machine" "vm" {
name = "vm-${local.prefix}-app-01"
resource_group_name = azurerm_resource_group.rg.name
location = local.location
size = "Standard_D2ds_v5"
admin_username = "azureadmin"
disable_password_authentication = true
admin_ssh_key {
username = "azureadmin"
public_key = file("~/.ssh/id_rsa.pub")
}
network_interface_ids = [azurerm_network_interface.nic.id]
os_disk {
caching = "ReadWrite"
storage_account_type = "Premium_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts-gen2"
version = "latest"
}
tags = local.tags
# Run the configure_web action after the VM is created or updated.
lifecycle {
action_trigger {
events = [after_create, after_update]
actions = [action.ansible_playbook.configure_web]
}
}
}
# --- Ansible inventory ---
resource "ansible_group" "webservers" {
name = "webservers"
variables = {
http_port = "80"
https_port = "443"
ansible_user = "azureadmin"
env = var.environment
}
}
resource "ansible_host" "vm" {
name = azurerm_linux_virtual_machine.vm.public_ip_address
groups = [ansible_group.webservers.name]
variables = {
ansible_ssh_private_key_file = "~/.ssh/id_rsa"
ansible_python_interpreter = "/usr/bin/python3"
vm_name = azurerm_linux_virtual_machine.vm.name
resource_group = azurerm_resource_group.rg.name
}
}
# --- Action: run a playbook ---
action "ansible_playbook" "configure_web" {
config {
playbook = "${path.module}/playbooks/configure_web.yml"
name = azurerm_linux_virtual_machine.vm.public_ip_address # inventory target
private_key = file("~/.ssh/id_rsa")
extra_vars = {
env = var.environment
vm_name = azurerm_linux_virtual_machine.vm.name
}
ansible_ssh_extra_args = "-o StrictHostKeyChecking=no"
}
}Lifecycle events that can trigger an action
| Event | Fires when |
|---|---|
after_create | Resource was just created |
after_update | Resource was updated in-place |
before_destroy | Resource is about to be destroyed (good for drain / deregister) |
before_create / before_update | Run a pre-flight before the provider call |
Multiple events can be combined into one action_trigger, and a resource can have multiple action_trigger blocks for different actions/events.
Ad-hoc invocation - re-run a playbook without changing infra
Because actions are top-level objects, they can be invoked without an associated resource change:
# Re-run the configure_web playbook against the current inventory
terraform apply -invoke=action.ansible_playbook.configure_web
# Plan only the action - shows what would happen
terraform plan -invoke=action.ansible_playbook.configure_webThis replaces the old “bump a trigger to force the playbook to re-run” hack.
Running a playbook against multiple hosts
When deploying several VMs with for_each, declare one action per VM and attach a trigger to each VM. Actions also support for_each:
resource "azurerm_linux_virtual_machine" "vms" {
for_each = local.vm_configs
# ...
lifecycle {
action_trigger {
events = [after_create, after_update]
actions = [action.ansible_playbook.configure_vms[each.key]]
}
}
}
resource "ansible_host" "vms" {
for_each = azurerm_linux_virtual_machine.vms
name = each.value.public_ip_address
groups = ["webservers"]
variables = {
ansible_user = "azureadmin"
ansible_ssh_private_key_file = "~/.ssh/id_rsa"
vm_name = each.value.name
}
}
action "ansible_playbook" "configure_vms" {
for_each = azurerm_linux_virtual_machine.vms
config {
playbook = "${path.module}/playbooks/configure_web.yml"
name = each.value.public_ip_address
private_key = file("~/.ssh/id_rsa")
extra_vars = { vm_name = each.value.name }
ansible_ssh_extra_args = "-o StrictHostKeyChecking=no"
}
}Actions vs. terraform_data + local-exec - why move
terraform_data + local-exec | action | |
|---|---|---|
| Execution model | Side-effect of a fake resource that lives in state | First-class, no state entry |
| Re-run without state churn | Requires bumping triggers_replace | terraform apply -invoke=... |
| Pre-destroy hooks | Awkward (when = destroy provisioner) | Native before_destroy event |
| Plan visibility | Shows as a resource replace | Shows as a discrete action call |
| Provider-native config | None - shells out | Typed config block validated by the provider |
terraform_data is still useful for one-off local-exec glue where no provider offers an action, but for Ansible/Kubernetes/scripted post-deploy steps the action model is the preferred pattern.
Pairing actions with ephemeral secrets
Actions accept ephemeral values in their config block, so SSH keys or vault-stored playbook variables can be sourced from Key Vault without ever hitting state:
ephemeral "azurerm_key_vault_secret" "deploy_key" {
name = "ansible-deploy-key"
key_vault_id = data.azurerm_key_vault.shared.id
}
action "ansible_playbook" "configure_web" {
config {
playbook = "${path.module}/playbooks/configure_web.yml"
name = azurerm_linux_virtual_machine.vm.public_ip_address
private_key = ephemeral.azurerm_key_vault_secret.deploy_key.value
}
}See Ephemeral Values & Write-Only Arguments for the full secret-handling story.
Provider chaining: the execution order problem
When a provider is configured using attributes of a resource (like the Kubernetes or Ansible examples), Terraform can’t initialise the provider until that resource exists. This means you cannot use -target to deploy just the upstream resource, because the downstream provider won’t initialise cleanly.
The recommended patterns to handle this:
# Option 1 - split into two root modules: one for infra, one for config
# Module 1: creates AKS, stores kubeconfig in state
# Module 2: reads kubeconfig via terraform_remote_state, configures Kubernetes
# Option 2 - use a local file as an intermediary
resource "local_file" "kubeconfig" {
content = azurerm_kubernetes_cluster.aks.kube_config_raw
filename = "${path.module}/.kube/config"
}
provider "kubernetes" {
config_path = local_file.kubeconfig.filename
}
# Option 3 - use a null-initialised provider during first apply (advanced)
# Set provider host to a dummy value; use -target to create the cluster first,
# then re-run without -target so the provider can resolve the real host.Anti-patterns
- ⚠️ Local state for team or production environments - local
.tfstatefiles can’t be locked, can’t be shared, and get lost. Use a remote backend (Azure Blob withuse_azuread_auth = true) from day one. - 🚨 Secrets in
.tfvarsfiles committed to git - evensensitive = truevariables end up in state. Use Key Vault data sources, ephemeral resources (TF 1.10+), or environment variables (TF_VAR_name) instead. - 🚨 Using
sensitive = trueas a substitute for not storing secrets - it redacts values from plan output but the plaintext is still written to the state file. Use ephemeral values or write-only arguments for secrets that must never land in state. - ⚠️
countfor resources that may be removed from the middle of a list - removing index0re-numbers all remaining resources and forces replacement. Usefor_eachwith stable string keys instead. - ⚠️
-targetas a normal workflow pattern - it leaves state partially applied, diverges from what the code describes, and makes subsequent plans unpredictable. Use it only to unblock a stuck apply, then run a full plan immediately after. - 🚨
timestamp()directly in resource tags - it re-evaluates on every plan, creating a perpetual diff. Use anexternaldata source that only runs during apply, or omit the timestamp from managed tags. - ⚠️ Provider configuration in child modules - providers should be declared and configured in the root module only. Child modules inherit them via the
providersmap. Provider config in a module makes it non-reusable. - 🔬 Running
terraform applywithout reviewing the plan - alwaysterraform plan -out tfplan.planfirst, thenterraform apply tfplan.plan. Skipping the plan file means the apply re-plans with potentially different inputs.