| name | avm-terraform-module-development |
| description | Azure Verified Modules (AVM) Terraform development workflow for reviewing, fixing, and extending Resource Modules and Pattern Modules |
| glob | **/*.terraform,**/*.tf,**/*.tfvars,**/*.tfstate,**/*.tflint.hcl,**/*.tf.json,**/*.tfvars.json |
Azure Verified Modules (AVM) Terraform
Azure Verified Modules (AVM) are pre-built, tested, and validated Terraform and Bicep modules that follow Azure best practices. Use this skill when reviewing, fixing, or extending an AVM Terraform module so the change stays aligned with the published AVM specifications.
Toolchain
This repository uses the Avm.Authoring PowerShell module. It replaces the old Makefile, AVM container, and Porch pipelines. Everything runs natively on Windows, Linux, and macOS under PowerShell 7.4+.
| Command | Purpose |
|---|
avm pre-commit | Mandatory pre-commit gauntlet: sync, convention check, transform, format, docs. |
avm pr-check | Mandatory post-commit gauntlet. |
avm test unit | Run tests/unit with mocked providers. |
avm test integration | Run tests/integration against real Azure. |
avm test e2e | Deploy, idempotency-check, and destroy examples. |
avm sync | Reconcile governance-managed files. |
avm transform, avm format, avm docs | Run individual fixers. |
avm lint, avm check convention, avm check policy | Run individual gates. |
Every command accepts -Path to target another directory and -Ecosystem terraform to skip ecosystem auto-detection. Commands report failure by throwing, so scripts should use try/catch instead of checking $LASTEXITCODE.
Before you start
1. Identify the module type
Look at the repo name and _header.md to classify the module. The naming convention is terraform-<provider>-avm-<type>-<name>:
| Type | Name token | Purpose |
|---|
| Resource Module | res | Deploys a single primary Azure resource plus its tightly-coupled children (e.g. terraform-azurerm-avm-res-storage-storageaccount). |
| Pattern Module | ptn | Composes multiple resource modules into an opinionated workload (e.g. terraform-azurerm-avm-ptn-aks-production). |
| Utility Module | utl | Helper module exposing shared inputs/outputs (e.g. Azure/avm-utl-interfaces/azure). |
The composition rules differ slightly per type. The shared rules below apply to resource and pattern modules — if you are working on a utility module, fetch its dedicated spec.
2. Read the right spec, on demand
Every AVM rule has an ID (e.g. TFRMFR1, TFFR4, SNFR3). When you need the authoritative text:
- Fetch the AVM spec index once per session:
https://azure.github.io/Azure-Verified-Modules/llms.txt
- Look up the raw markdown URL for the spec ID you need. URLs follow the pattern:
https://raw.githubusercontent.com/Azure/Azure-Verified-Modules/refs/heads/main/docs/content/specs-defs/includes/terraform/<scope>/<functional|non-functional>/<ID>.md
- Fetch and read the specific spec markdown.
Never cite a spec ID without first confirming its current text from the index — wording and severity can change.
3. Understand severity prefixes
AVM uses RFC 2119 keywords: MUST, SHOULD, MAY. Treat MUST rules as blocking and SHOULD as defaults that need an explicit reason to skip.
Module composition checklist
Before you claim a change is done, verify the module still satisfies these MUST-level gates. For full text, look up each ID via llms.txt.
Repository & file layout
RMNFR1 — Module names follow terraform-<provider>-avm-<res|ptn|utl>-<service>[-<descriptor>]. The approved name is the one in the module proposal / module-index CSV — don't construct it yourself.
TFNFR39 — Standard file layout: main.tf, variables.tf, outputs.tf, terraform.tf are MUST; locals.tf is required if any locals exist. Files MAY be split as main.<topic>.tf / variables.<topic>.tf / outputs.<topic>.tf / locals.<topic>.tf using the canonical prefix. The terraform {} block appears exactly once, in terraform.tf. No providers.tf at module root.
TFNFR2 / SNFR15 — README.md is auto-generated. Edit _header.md (and _footer.md) only — they are required inputs to docs generation, in every submodule too.
TFNFR4 — snake_case everywhere in Terraform code.
Inputs & outputs
TFNFR1 / TFNFR17 / TFNFR18 — Every variable and output has a description and a precise type.
TFNFR20 — Collection variables (map, set, list) default to {} / [] with nullable = false rather than null.
RMFR7 / TFFR2 / TFNFR16 — Outputs follow AVM minimum requirements and naming rules. For Terraform-specific additional outputs, prefer discrete computed attributes over whole resource object outputs.
TFRMFR1 — Resource Module Parent ID: expose the parent scope as a single string variable named parent_id, nullable = false, no default. Assign it to parent_id on every primary azapi_resource. Modules MUST NOT accept resource_group_name, resource_group_resource_id, or any other parent-scope-specific variable. Modules MUST NOT create the parent scope themselves (supersedes the Terraform clause of RMFR3). Submodules typically receive parent_id = azapi_resource.this.id from the parent.
TFNFR38 — Validate parent_id with can(provider::azapi::parse_resource_id("<ExpectedParentType>", var.parent_id)). The expected parent type MUST be a literal string (e.g. "Microsoft.Resources/resourceGroups" or "Microsoft.Network/virtualNetworks"). Hand-rolled regex/startswith/length checks are not allowed. Extension-resource modules (locks, role assignments, diagnostic settings, tags, etc.) are the only exception and use a generic startswith check on /subscriptions/ or /providers/, with the reason documented in the README.
Resource implementation
TFRMNFR2 — Primary Resource Naming: the primary azapi_resource (or equivalent AzAPI resource) MUST be named this. Every satellite resource (lock, role assignments, diagnostic settings, private endpoints, child resources required by the primary, etc.) MUST NOT be named this — it MUST be named after what it represents (e.g. azapi_resource.lock, azapi_resource.role_assignments, azapi_resource.diagnostic_settings). Each submodule has its own this. This is what lets consumers and the AVM interface utility module rely on azapi_resource.this.id and azapi_resource.this.output.
TFRMNFR1 — Subresources as submodules: every ARM subresource (a child resource type in the API spec) MUST be implemented as a Terraform submodule under modules/<singular-subresource-name>/. Submodules are full AVM modules in their own right (same shared/RM/TF specs apply), each with their own _header.md and _footer.md. Submodules MUST NOT declare count / for_each on their primary azapi_resource — cardinality is the parent's responsibility. Parent modules MUST reference submodules by local relative path (./modules/<name>), not via the registry or git.
TFFR3 — Resources are implemented with the AzAPI provider (Azure/azapi >= 2.0, < 3.0). Only fall back to azurerm (preferring data sources) when AzAPI genuinely lacks an equivalent; document the reason in code and in README.md per the exception requirements.
TFFR4 — Every azapi_resource MUST specify response_export_values, even if it is []. Use it (list or map form) to surface read-only properties needed by the module's outputs.
TFFR5 — Every azapi_resource MUST specify replace_triggers_refs, listing the body paths that should force replacement when changed. name and location already trigger replacement and don't need to be listed.
For full AzAPI patterns, the parent_id variable shape, the Get-AzureSchema lookup CLI, and provider configuration, read AzAPI.md.
Telemetry, providers, and required versions
SFR3 / SFR4 — main.telemetry.tf must declare the modtm telemetry resource gated on var.enable_telemetry. Do not remove or rename it.
TFFR3 / TFNFR26 — Pin required_providers versions (Azure/azapi, Azure/modtm, hashicorp/random, any other providers used) in the single terraform {} block in terraform.tf. AzAPI version policy is governed by TFFR3.
TFNFR27 — No provider configuration blocks in modules (only required_providers). Provider configuration belongs in the consumer's root module.
The mapotf pre-commit configs enforce the telemetry block and provider versions automatically — do not hand-edit the files they generate. The configs ship inside the Avm.Authoring module and are applied by avm transform (which avm pre-commit runs for you); a repository may override them by committing its own config/mapotf/pre-commit/ directory.
Standard interfaces
AVM defines a fixed set of standard interfaces that resource modules expose where the underlying Azure resource supports them. They standardise variable names, types, and behaviour across every module:
- Resource features (apply only when the underlying resource supports them): diagnostic settings (v2 schema), role assignments, locks, managed identities, private endpoints, customer-managed keys, tags.
- AzAPI mechanics (apply to every module):
resource_types (API-version pinning per azapi_resource, module-specific keys, cascaded to submodules), retry (assigned as an attribute), timeouts (emitted via a dynamic "timeouts" block).
The resource-feature interfaces are backed by the shared utility module Azure/avm-utl-interfaces/azure — compose it rather than redefining variable shapes by hand. The diagnostic-settings interface MUST use the v2 shape (diagnostic_settings_v2 input / diagnostic_settings_azapi_v2 output on the utility module).
For variable shapes, defaults, the v2 diagnostic-settings details, and which interfaces apply to which resource, read interfaces.md. For the retry / timeouts variable schemas and the required dynamic "timeouts" wiring on azapi_resource, read AzAPI.md.
Module composition reference
For a single concise summary of how a resource or pattern module fits together (file layout, parent-child resource splitting, sub-module rules, examples folder conventions), read module-composition.md.
Development Workflow
Follow these steps in order when fixing an issue or adding a feature.
Step 0: Load the AVM toolchain
Every avm command below comes from the Avm.Authoring PowerShell module. Install it once, then import it in each PowerShell session:
Install-PSResource -Name Avm.Authoring -Repository PSGallery -TrustRepository
Import-Module Avm.Authoring
The module downloads and pins the tools it needs (terraform, tflint, terraform-docs, conftest, mapotf) on first use, so there is no separate install step. Set AVM_NO_AUTO_INSTALL=1 in air-gapped environments and pre-warm the cache with avm tool install <name> instead.
The repository-root ./avm and avm.bat compatibility launchers remain temporarily, but only print migration guidance and exit with an error. The avm.ps1 launcher has been removed. Run avm from an imported Avm.Authoring PowerShell module, and do not set PORCH_NO_TUI.
Step 1: Start from a clean main branch
git checkout main
git pull
Step 2: Create and checkout a feature/issue branch
git checkout -b feature/<short-description>
# or
git checkout -b fix/<issue-number>-<short-description>
Step 3: Implement the change
Make the necessary code changes, keeping the composition checklist above in mind.
For AzAPI resource patterns, schema lookups, and the Get-AzureSchema CLI tool, read AzAPI.md. To query Terraform provider schemas (resources, data sources, functions, ephemeral resources), use the tfpluginschema CLI — see tfpluginschema.md.
Step 4: Add unit tests (if justified)
Unit tests use provider mocking and live in the tests/unit directory. Add or update unit tests when your change introduces new logic, variables, or outputs that can be validated without deploying real infrastructure. For test writing guidance, syntax, and patterns, read terraform-test.md.
avm test unit
Step 5: Add integration tests (if justified)
Integration tests do not use provider mocking and live in the tests/integration directory. Add or update integration tests when your change requires validation against real Azure infrastructure. For test writing guidance, syntax, and patterns, read terraform-test.md.
avm test integration
Step 6: Add or update examples (if justified)
If your change affects module usage or introduces new functionality, add or update examples in the examples/ directory. Test only the pertinent example:
avm test e2e -Example <ExampleDir>
Run avm test e2e -List to see runnable examples; add an .e2eignore file to exclude one. When distributing tests across multiple Azure subscriptions or retaining deployed resources for manual validation, see example-test.md.
Step 7: Update documentation (if justified)
If documentation changes are needed, edit _header.md. NEVER edit README.md directly -- it is auto-generated and will be overwritten.
Step 8: Run pre-commit checks (MANDATORY)
This must always be run before committing:
avm pre-commit
Step 9: Commit changes
git add .
git commit -m "<type>: <meaningful description>"
Step 10: Run PR checks (MANDATORY)
This must always be run after committing:
avm pr-check
Step 11: Push and open a PR
Push the branch to remote and open a pull request with a meaningful description. Reference any issues that should be closed.
git push -u origin HEAD
When creating the PR, include:
- A summary of the change.
- The issue number(s) the PR closes.
- Any relevant context for reviewers.
Common mistakes to avoid
- Citing a spec from memory. AVM specs change. Always fetch the current text via
llms.txt. Several spec IDs are easy to mix up (e.g. TFFR4 is response_export_values, TFFR5 is replace_triggers_refs, TFFR6 is resource_types, TFFR7 is retry/timeouts).
- Reaching for
azurerm. TFFR3 requires AzAPI; only fall back to azurerm for genuinely missing capabilities, and document why.
- Naming the primary resource anything other than
this (TFRMNFR2), or naming a satellite resource this. The primary azapi_resource MUST be this; satellites MUST be named after what they represent (lock, role_assignments, diagnostic_settings, ...).
- Exposing
resource_group_name (or any other parent-scope-specific variable) instead of parent_id (TFRMFR1), or validating parent_id with hand-rolled regex/startswith instead of can(provider::azapi::parse_resource_id("<ExpectedParentType>", var.parent_id)) (TFNFR38).
- Creating the parent scope inside the module (e.g. a
Microsoft.Resources/resourceGroups azapi_resource for the resource group the module deploys into) — TFRMFR1 forbids this; the consumer supplies an existing scope's ARM ID.
- Hard-coding the
type argument on an azapi_resource instead of sourcing it from var.resource_types (TFFR6), or forgetting to cascade the relevant subset to each submodule.
- Omitting
response_export_values (TFFR4) or replace_triggers_refs (TFFR5) — both are MUST on every azapi_resource, even when the value is [].
- Editing
README.md, , or provider versions by hand. These are generated/enforced — edit , the source via mapotf configs, and so on.
Specifications
The canonical source of every AVM rule is the spec index:
Fetch llms.txt first, locate the raw markdown URL for the spec ID you care about, then fetch that markdown. Do not hard-code spec URLs into module source.