AZD with Terraform
Use Azure Developer CLI (azd) with Terraform as the infrastructure provider.
When to Use
Choose azd+Terraform when you want:
- Terraform's multi-cloud capabilities with azd's deployment simplicity
- Existing Terraform expertise but want
azd upconvenience - Team familiar with Terraform but needs environment management
- Multi-cloud IaC with Azure-first deployment experience
Benefits
| Feature | Pure Terraform | AZD + Terraform |
|---|---|---|
| Deploy command | terraform apply | azd up |
| Environment management | Manual workspaces | Built-in azd env |
| CI/CD generation | Manual setup | Auto-generated pipelines |
| Service deployment | Manual scripts | Automatic from azure.yaml |
| State management | Manual backend setup | Configurable |
| Multi-cloud | ✅ Yes | ✅ Yes |
Configuration
1. azure.yaml Structure
Create azure.yaml in project root:
name: myapp
metadata:
template: azd-init
# Specify Terraform as IaC provider
infra:
provider: terraform
path: ./infra
# Define services as usual
services:
api:
project: ./src/api
language: python
host: containerapp
docker:
path: ./src/api/Dockerfile
web:
project: ./src/web
language: js
host: staticwebapp
dist: dist2. Terraform File Structure
Place Terraform files in ./infra/:
infra/
├── main.tf # Main resources
├── variables.tf # Variable definitions
├── outputs.tf # Output values
├── provider.tf # Provider configuration
└── modules/
├── api/
│ └── main.tf
└── web/
└── main.tf3. Provider Configuration
provider.tf:
terraform {
required_version = ">= 1.5.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.2"
}
azurecaf = {
source = "aztfmod/azurecaf"
version = "~> 1.2"
}
}
# Optional: Remote state for team collaboration
backend "azurerm" {
resource_group_name = "rg-terraform-state"
storage_account_name = "tfstate${var.state_suffix}"
container_name = "tfstate"
key = "app.terraform.tfstate"
}
}
provider "azurerm" {
features {}
}⚠️ IMPORTANT: For Azure Functions Flex Consumption, use azurerm provider v4.2 or later:
terraform { required_providers { azurerm = { source = "hashicorp/azurerm" version = "~> 4.2" } } }See Terraform Functions patterns for Flex Consumption examples.
4. Variables and Outputs
⚠️ WARNING: Use
${VAR}syntax inmain.tfvars.json, NOT Go-style{{ .Env.* }}
>
azd's template engine processes
azure.yamland service manifests — it does NOT interpolate Go-style{{ .Env.* }}template variables in.tfvars.jsonor any Terraform variable files. Literal strings like{{ .Env.AZURE_ENV_NAME }}will be passed directly to Terraform, causing deployment failures.
>
azd reads
infra/main.tfvars.json, substitutes${VAR}references using its built-in envsubst, and passes the resolved file to Terraform via-var-file=. Use this pattern:
>
{ "environment_name": "${AZURE_ENV_NAME}", "location": "${AZURE_LOCATION}", "subscription_id": "${AZURE_SUBSCRIPTION_ID}" }
>
For additional variables not in
main.tfvars.json, useTF_VAR_*environment variables:azd env set TF_VAR_myvar value
variables.tf:
variable "environment_name" {
type = string
description = "Environment name from azd"
}
variable "location" {
type = string
description = "Azure region"
default = "eastus2"
}
variable "principal_id" {
type = string
description = "User principal ID from azd auth"
default = ""
}outputs.tf:
# Required: Resource group name
output "AZURE_RESOURCE_GROUP" {
value = azurerm_resource_group.main.name
}
# Service-specific outputs
output "API_URL" {
value = azurerm_container_app.api.latest_revision_fqdn
}
output "WEB_URL" {
value = azurerm_static_web_app.web.default_host_name
}💡 Tip: Output names in UPPERCASE are automatically set as azd environment variables.
5. Required Tags for azd
CRITICAL: Tag hosting resources with service names from azure.yaml:
resource "azurerm_container_app" "api" {
name = "ca-${var.environment_name}-api"
resource_group_name = azurerm_resource_group.main.name
# Required for azd deploy to find this resource
tags = merge(var.tags, {
"azd-service-name" = "api" # Matches service name in azure.yaml
})
# ... rest of configuration
}
resource "azurerm_static_web_app" "web" {
name = "swa-${var.environment_name}-web"
resource_group_name = azurerm_resource_group.main.name
# Required for azd deploy to find this resource
tags = merge(var.tags, {
"azd-service-name" = "web" # Matches service name in azure.yaml
})
# ... rest of configuration
}⚠️ WARNING: Without
azd-service-nametags,azd deploywill fail to find deployment targets.
6. Resource Group Tags
Tag the resource group with environment name:
resource "azurerm_resource_group" "main" {
name = "rg-${var.environment_name}"
location = var.location
tags = {
"azd-env-name" = var.environment_name
}
}Deployment Workflow
Initial Setup
# 1. Create azd environment
azd env new dev --no-prompt
# 2. Set required variables
azd env set AZURE_LOCATION eastus2
# 3. Provision infrastructure (runs terraform init, plan, apply)
azd provision
# 4. Deploy services
azd deploy
# Or do both with single command
azd upVariables and State
azd environment variables → Terraform variables
azd passes variables to Terraform through main.tfvars.json (with ${VAR} substitution) or explicit TF_VAR_* environment variables. Define the variable in variables.tf and reference it in main.tfvars.json.
infra/main.tfvars.json — azd substitutes ${VAR} references via envsubst:
{
"environment_name": "${AZURE_ENV_NAME}",
"location": "${AZURE_LOCATION}",
"database_name": "${DATABASE_NAME}"
}variables.tf — value provided via main.tfvars.json or TFVARdatabase_name:
variable "database_name" {
type = string
}For variables not in main.tfvars.json, use TF_VAR_* environment variables:
azd env set TF_VAR_custom_setting "my-value"⚠️ Use
${VAR}syntax inmain.tfvars.json, NOT Go-style{{ .Env.* }}. azd substitutes${VAR}references using its built-in envsubst. Go-style template variables are only processed inazure.yamland service manifests. See Variables and Outputs for details.
Remote state setup:
# Create state storage (one-time setup)
az group create --name rg-terraform-state --location eastus2
az storage account create \
--name tfstate<unique> \
--resource-group rg-terraform-state \
--sku Standard_LRS
az storage container create \
--name tfstate \
--account-name tfstate<unique>
# Set backend variables for azd
azd env set TF_STATE_RESOURCE_GROUP rg-terraform-state
azd env set TF_STATE_STORAGE_ACCOUNT tfstate<unique>Generation Steps
When preparing a new azd+Terraform project:
- Generate azure.yaml with
infra.provider: terraform - Create Terraform files in
./infra/:
main.tf- Core resources and resource groupvariables.tf- environment_name, location, tagsoutputs.tf- Service URLs and resource names (UPPERCASE)provider.tf- azurerm provider + backend config
- Add required tags:
- Resource group:
azd-env-name - Hosting resources:
azd-service-name(matches azure.yaml services)
- Research best practices - Call
mcp_azure_mcp_azureterraformbestpractices
AVM Terraform Module Priority
For Terraform module selection, enforce this order:
- AVM Terraform Pattern Modules
- AVM Terraform Resource Modules
- AVM Terraform Utility Modules
Use mcp_azure_mcp_documentation (azure-documentation) for current guidance and AVM context first, then use Context7 only as supplemental examples if required. If Context7 is not available, instruct the user to install it:
npx @upstash/context7-mcp@latestMigration from Pure Terraform
Converting existing Terraform project to use azd:
- Create
azure.yamlwith services andinfra.provider: terraform - Move
.tffiles to./infra/directory - Add
azd-service-nametags to hosting resources - Ensure outputs include service URLs in UPPERCASE
- Test with
azd provisionandazd deploy
CI/CD Integration
azd can auto-generate pipelines for Terraform:
# Generate GitHub Actions workflow
azd pipeline config
# Generate Azure DevOps pipeline
azd pipeline config --provider azdoGenerated pipelines will:
- Install Terraform
- Run
terraform init,plan,apply - Use azd authentication
- Deploy services with
azd deploy
Comparison: azd+Terraform vs Pure Terraform
| Aspect | Pure Terraform | azd + Terraform |
|---|---|---|
| IaC | Terraform | Terraform |
| Provision | terraform apply | azd provision (wraps terraform) |
| Deploy apps | Manual scripts | azd deploy (automatic) |
| Environment mgmt | Workspaces | azd env |
| Auth | Manual az login | azd auth login |
| CI/CD | Manual setup | azd pipeline config |
| Multi-service | Manual orchestration | Automatic from azure.yaml |
| Learning curve | Medium | Low |
When NOT to Use azd+Terraform
Use pure Terraform (without azd) when:
- Multi-cloud deployment (not Azure-first)
- Complex Terraform modules/workspaces that conflict with azd conventions
- Existing complex Terraform CI/CD that's hard to migrate
- Team has strong Terraform expertise but no bandwidth for azd learning
Azure Policy Compliance
Enterprise Azure subscriptions typically enforce security policies. Your Terraform must comply:
Storage Account (Required for Functions)
resource "azurerm_storage_account" "storage" {
name = "stmyapp${random_string.suffix.result}"
resource_group_name = azurerm_resource_group.rg.name
location = azurerm_resource_group.rg.location
account_tier = "Standard"
account_replication_type = "LRS"
# Azure policy requirements
allow_nested_items_to_be_public = false # Disable anonymous blob access
local_user_enabled = false # Disable local users
shared_access_key_enabled = false # RBAC-only, no access keys
}Function App with Managed Identity Storage
provider "azurerm" {
features {}
storage_use_azuread = true # Required when shared_access_key_enabled = false
}
resource "azurerm_linux_function_app" "function" {
name = "func-myapp"
resource_group_name = azurerm_resource_group.rg.name
location = azurerm_resource_group.rg.location
service_plan_id = azurerm_service_plan.plan.id
storage_account_name = azurerm_storage_account.storage.name
storage_uses_managed_identity = true # Use MI instead of access key
identity {
type = "SystemAssigned"
}
tags = {
"azd-service-name" = "api" # REQUIRED for azd deploy
}
depends_on = [azurerm_role_assignment.deployer_storage]
}
# RBAC for deploying user (create function with MI storage)
resource "azurerm_role_assignment" "deployer_storage" {
scope = azurerm_storage_account.storage.id
role_definition_name = "Storage Blob Data Owner"
principal_id = data.azurerm_client_config.current.object_id
}
# RBAC for function app after creation
resource "azurerm_role_assignment" "function_storage" {
scope = azurerm_storage_account.storage.id
role_definition_name = "Storage Blob Data Owner"
principal_id = azurerm_linux_function_app.function.identity[0].principal_id
}Services with Disabled Local Auth
# Service Bus
resource "azurerm_servicebus_namespace" "sb" {
local_auth_enabled = false # RBAC-only
}
# Event Hubs
resource "azurerm_eventhub_namespace" "eh" {
local_authentication_enabled = false # RBAC-only
}
# Cosmos DB
resource "azurerm_cosmosdb_account" "cosmos" {
local_authentication_disabled = true # RBAC-only
}Troubleshooting
| Issue | Solution |
|---|---|
resource not found: unable to find a resource tagged with 'azd-service-name' | Add azd-service-name tag to hosting resource in Terraform |
RequestDisallowedByPolicy: shared key access | Set shared_access_key_enabled = false on storage |
RequestDisallowedByPolicy: local auth disabled | Set local_auth_enabled = false on Service Bus |
RequestDisallowedByPolicy: anonymous blob access | Set allow_nested_items_to_be_public = false on storage |
terraform command not found | Install Terraform CLI: brew install terraform or download from terraform.io |
| State conflicts | Configure remote backend in provider.tf |
| Variable not passed to Terraform | Ensure variable is set with azd env set and defined in variables.tf |
Literal {{ .Env.* }} in Terraform errors | Use ${VAR} syntax in main.tfvars.json, not Go-style {{ .Env.* }}. azd substitutes ${VAR} references via envsubst |
main.tfvars.json interpolation failure | Ensure main.tfvars.json uses ${VAR} syntax (e.g., ${AZURE_ENV_NAME}), not Go-style {{ .Env.* }} templates |