Terraform Patterns
Alternative-path patterns for AppOnboard scaffold. Used when .tf files detected or user overrides iacFormat. Per-resource config comes from mcp_azure_mcp_azureterraformbestpractices at runtime โ this file covers layout, provider, naming, state, tagging, and wiring.
File Structure
Default (greenfield or user override)
infra/
โโโ main.tf # Root module โ provider, resource group, module calls
โโโ variables.tf # Input variables (all configurable values)
โโโ outputs.tf # Exported values (endpoints, resource IDs)
โโโ backend.tf # State backend config (local default, Azure Storage for prod)
โโโ terraform.tfvars # Default variable values (from prepare-plan.json)
โโโ modules/
โโโ app-service/
โ โโโ main.tf
โ โโโ variables.tf
โ โโโ outputs.tf
โโโ container-app/
โโโ sql-database/
โโโ key-vault/
โโโ log-analytics/
โโโ ...Non-Azure IaC coexistence (GCP/AWS TF already in repo)
When detectedInfraProvider.terraform is "gcp", "aws", or "multi" (without azurerm), write Azure TF to a separate directory from existing non-Azure TF. Never overwrite or modify existing IaC files.
Output directory rule: If existing TF is NOT at infra/, write to infra/. If existing TF IS at infra/ (or any path containing infra), write to infra-azure/. Same module structure as default layout.
Each Azure service gets its own module. main.tf orchestrates resource group + module calls.
Provider Configuration
terraform {
required_version = ">= 1.5"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
random = {
source = "hashicorp/random"
version = "~> 3.0"
}
}
}
provider "azurerm" {
features {}
subscription_id = var.subscription_id
resource_provider_registrations = "none"
}โ Never pin to exact patch versions (e.g.,
= 4.1.0). Use~> 4.0to allow minor/patch updates.azurermmanages API versions internally โ if a resource isn't available inazurerm, useazapi_resourcewith the latest stable ARM API version.
Conditional access (AADSTS530084):
azurermprovider re-requests tokens that violate device-binding policies. Fix: (1) switch to Bicep, or (2) use service principal auth (ARM_CLIENT_ID+ARM_CLIENT_SECRET+ARM_TENANT_ID).
variables.tf
Required variables: environment_name (string, default "dev"), location (string, default "eastus"), subscription_id (string), session_id (string), deployed_by (string). All configurable values MUST be variables โ no hardcoded regions, names, or SKUs.
terraform.tfvars
Populate from prepare-plan.json: environment_name, location, subscription_id, session_id. See naming-patterns.md for naming convention.
Backend
Local backend by default: backend "local" { path = "terraform.tfstate" }. Recommend Azure Storage backend in postDeployRecommendations[] for production.
Resource Group
Use rg-${var.project_name}-${var.environment_name}-${random_string.suffix.result} with tags = local.tags. Suffix prevents collisions across AppOnboard sessions.
Naming Convention
resource "random_string" "suffix" {
length = 4
special = false
upper = false
}
locals {
# Pattern: {type}-{appname}-{env}-{suffix}
app_name = "app-${var.environment_name}-${random_string.suffix.result}"
kv_name = "kv-${var.environment_name}-${random_string.suffix.result}"
sql_name = "sql-${var.environment_name}-${random_string.suffix.result}"
# Storage/ACR: alphanumeric only, no hyphens
storage_name = "st${replace(var.environment_name, "-", "")}${random_string.suffix.result}"
acr_name = "cr${replace(var.environment_name, "-", "")}${random_string.suffix.result}"
}Cross-reference naming with prepare/references/naming-patterns.md โ Terraform names must match prepare-plan.json.naming.resources[].
Resource Tags โ Mandatory
Apply all 5 AppOnboard tags via local.tags โ see iac-generation-rules.md ยง Session Tags for tag names and values.
locals {
tags = {
"app-onboard-skill" = "true"
"app-onboard-session-id" = var.session_id
"created-at" = timestamp()
"environment" = var.environment_name
"deployed-by" = var.deployed_by
}
}โ ๏ธ
timestamp()changes on every plan. Addlifecycle { ignore_changes = [tags["created-at"]] }on every resource.
Secrets โ random_password, Not random_string
resource "random_password" "db_password" {
length = 32
special = true
lifecycle { ignore_changes = [result] }
}
resource "azurerm_key_vault_secret" "db_password" {
name = "db-password"
value = random_password.db_password.result
key_vault_id = azurerm_key_vault.kv.id
}โ NEVER use
random_stringfor secrets โ it is not markedsensitivein state. Always userandom_password.
Container Apps โ Two-Phase Wiring
Same circular dependency as Bicep โ see bicep-container-apps.md. Phase 1: placeholder image, no ACR/KV refs. Phase 2: build + push, assign AcrPull, update via az containerapp update --image outside Terraform.
# Phase 1: Placeholder image
resource "azurerm_container_app" "app" {
template {
container {
name = "app"
image = "mcr.microsoft.com/azuredocs/containerapps-helloworld:latest"
cpu = 0.25
memory = "0.5Gi"
}
}
identity {
type = "SystemAssigned"
}
}outputs.tf
Export: resource_group_name, app_url (https://${hostname}), resource_ids (list of all deployed resource IDs for deploy-result.json).
Security Defaults
Apply same security rules as Bicep โ see bicep-patterns-security.md. Terraform-specific syntax:
| Rule | Terraform HCL |
|---|---|
| Managed identity | identity { type = "SystemAssigned" } |
| โ No SQL admin password | azuread_authentication_only = true. Never generate administrator_login_password |
| Key Vault RBAC | enable_rbac_authorization = true on azurerm_key_vault |
| KV secret reference | app_settings = { KEY = "@Microsoft.KeyVault(VaultName=..;SecretName=..)" } |
| HTTPS only | https_only = true, minimum_tls_version = "1.2" |
| Storage | https_traffic_only_enabled = true, allow_nested_items_to_be_public = false, min_tls_version = "TLS1_2" |
| โ Cosmos DB RBAC | azurerm_cosmosdb_sql_role_assignment, NOT azurerm_role_assignment โ see rbac-roles.md |
| RBAC assignments | principal_type = "ServicePrincipal" REQUIRED โ see rbac-roles.md |
| SCM/FTP auth | scm.allow: true (scaffold), ftp.allow: false (always) โ use azapi_resource |