Module Patterns Reference — Terraform Module Creator
Common patterns for Azure Terraform modules. Use these as starting points, not templates to copy blindly. Adapt to the actual requirement.
Standard Module File Structure
module/
├── main.tf # Primary resource declarations
├── variables.tf # Input variable definitions
├── outputs.tf # Output value definitions
├── versions.tf # Terraform and provider version constraints
├── locals.tf # Local value computations (add only if needed)
├── .terraform-docs.yml # terraform-docs configuration
├── .tflint.hcl # tflint static analysis configuration
└── README.md # Purpose, usage example, generated inputs/outputs tableOnly add locals.tf when it improves readability. Do not add files for the sake of neatness.
The inputs/outputs table in README.md is generated by terraform-docs, not written by hand. Always include .terraform-docs.yml and the marker comments in README.md.
terraform-docs Configuration
Always generate a .terraform-docs.yml file in the module root.
# .terraform-docs.yml
formatter: "markdown table"
version: ""
header-from: main.tf
footer-from: ""
recursive:
enabled: false
sections:
hide: []
show: []
content: |-
{{ .Header }}
## Usage
See the [Usage Example](#usage-example) section below.
{{ .Requirements }}
{{ .Providers }}
{{ .Modules }}
{{ .Resources }}
{{ .Inputs }}
{{ .Outputs }}
output:
file: README.md
mode: inject
template: |-
<!-- BEGIN_TF_DOCS -->
{{ .Content }}
<!-- END_TF_DOCS -->
sort:
enabled: true
by: requiredKey settings:
mode: inject— terraform-docs injects content between the marker comments in README.md, preserving everything outside the markerssort.by: required— required variables appear first, optional (with defaults) afterformatter: "markdown table"— renders inputs and outputs as clean markdown tables
Run after any variable or output changes:
terraform-docs .Install terraform-docs:
# macOS
brew install terraform-docs
# Linux
curl -Lo ./terraform-docs.tar.gz https://github.com/terraform-docs/terraform-docs/releases/latest/download/terraform-docs-linux-amd64.tar.gz
tar -xzf terraform-docs.tar.gz && chmod +x terraform-docs && mv terraform-docs /usr/local/bin/
# Via go
go install github.com/terraform-docs/terraform-docs@latestREADME.md Template
Generate a README with this structure. The <!-- BEGIN_TF_DOCS --> / <!-- END_TF_DOCS --> block is populated by terraform-docs . — everything outside it is maintained by hand.
# terraform-<resource-type>
> One-sentence description of what this module does and why it exists.
## Purpose
Brief explanation of the module's responsibility and the problem it solves.
## Module boundary
- **Owns:** list what the module creates
- **Expects from consumers:** list what must be provided (VNet IDs, DNS zone IDs, workspace IDs)
- **Does not own:** list what remains outside the module
## Usage example
```hcl
module "example" {
source = "git::https://github.com/your-org/terraform-modules.git//modules/<name>?ref=v1.0.0"
name = "<name>"
resource_group_name = "<rg>"
location = "uksouth"
tags = {
environment = "prod"
project = "myapp"
owner = "platform-team"
cost-center = "CC-12345"
}
}Assumptions and trade-offs
- list any important design decisions or assumptions
- note what has been left out deliberately
**Important:** Do not manually edit the content between the markers. Run `terraform-docs .` after any change to variables or outputs.
---
## pre-commit Hook (Optional)
If the team uses pre-commit, add this hook to `.pre-commit-config.yaml` in the repository root to auto-regenerate the README on every commit:
```yaml
repos:
- repo: https://github.com/terraform-docs/terraform-docs
rev: "v0.19.0" # pin to latest stable
hooks:
- id: terraform-docs-go
args: ["--config", ".terraform-docs.yml", "."]This ensures the README is always in sync with the module code without a manual step.
versions.tf Pattern
Always pin to a specific minimum version. Use mcp_terraform_get_latest_provider_version to get the current latest before generating.
terraform {
required_version = ">= 1.5.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = ">= 4.0.0"
}
}
}Guidance:
- Use
>=with a minimum — not an exact pin — to allow patch upgrades in the consumer - Always include
required_versionfor Terraform itself - Add
azureadorrandomonly if the module genuinely needs them
variables.tf Pattern
# --- Required variables (no default) ---
variable "name" {
description = "Name of the resource. Must be globally unique."
type = string
}
variable "resource_group_name" {
description = "Name of the resource group to deploy into."
type = string
}
variable "location" {
description = "Azure region for the resource (e.g. 'uksouth', 'westeurope')."
type = string
}
# --- Optional variables (with safe defaults) ---
variable "sku" {
description = "SKU tier for the resource. Defaults to Standard."
type = string
default = "Standard"
validation {
condition = contains(["Basic", "Standard", "Premium"], var.sku)
error_message = "sku must be one of: Basic, Standard, Premium."
}
}
variable "tags" {
description = "Map of tags to apply to all resources created by this module."
type = map(string)
default = {}
}
variable "diagnostic_settings" {
description = "Optional diagnostic settings to forward logs and metrics to a Log Analytics workspace."
type = object({
enabled = bool
log_analytics_workspace_id = optional(string)
retention_days = optional(number, 30)
})
default = {
enabled = false
}
}Guidance:
- Group required variables first, optional variables after
- Every variable needs a
description - Use
validationblocks for enum-style variables — avoid magic values - Use
optional()inside object types for partial configuration - Avoid large
object()variables that expose every possible resource argument
outputs.tf Pattern
output "id" {
description = "Resource ID of the deployed resource."
value = azurerm_example.this.id
}
output "name" {
description = "Name of the deployed resource."
value = azurerm_example.this.name
}
output "principal_id" {
description = "Principal ID of the system-assigned managed identity."
value = azurerm_example.this.identity[0].principal_id
}
output "primary_connection_string" {
description = "Primary connection string. Sensitive — do not log or expose in plan output."
value = azurerm_example.this.primary_connection_string
sensitive = true
}Guidance:
- Always output
id— consumers almost always need it - Output
name— useful for reference - Output
principal_idif the resource has a managed identity - Do not output every attribute — only those consumers realistically need
- Mark
sensitive = trueon any output that contains a secret: connection strings, access keys, passwords, SAS tokens - Prefer outputting the managed identity
principal_idover a connection string — guide consumers toward keyless authentication
Variable Validation Blocks
Add validation blocks to catch invalid inputs at plan time, before any resource is touched. This is cleaner than buried conditionals or cryptic provider errors.
variable "account_tier" {
description = "Storage account tier. Standard for general use, Premium for high IOPS."
type = string
default = "Standard"
validation {
condition = contains(["Standard", "Premium"], var.account_tier)
error_message = "account_tier must be 'Standard' or 'Premium'."
}
}
variable "name" {
description = "Name of the resource. Must be 3–24 lowercase alphanumeric characters."
type = string
validation {
condition = can(regex("^[a-z0-9]{3,24}$", var.name))
error_message = "name must be 3–24 lowercase alphanumeric characters (no hyphens)."
}
}
variable "retention_days" {
description = "Number of days to retain soft-deleted blobs. Must be between 1 and 365."
type = number
default = 7
validation {
condition = var.retention_days >= 1 && var.retention_days <= 365
error_message = "retention_days must be between 1 and 365."
}
}Guidance:
- Add validation to enum-style variables (SKUs, tiers, kinds) — never rely on the provider to surface the error clearly
- Add validation to name variables that have format constraints (storage account names, Key Vault names)
- Keep error messages short and actionable — state the constraint, not just that it failed
- Use
can()withregex()for pattern validation — avoids throwing on non-matching strings
Lifecycle Preconditions and Postconditions
Use lifecycle preconditions to enforce cross-variable constraints that cannot be expressed in a single validation block. Available in Terraform >= 1.2.
Precondition — checked before the resource is created or updated:
resource "azurerm_private_endpoint" "this" {
count = var.private_endpoint.enabled ? 1 : 0
# ...
lifecycle {
precondition {
condition = var.private_endpoint.subnet_id != null
error_message = "private_endpoint.subnet_id must be provided when private_endpoint.enabled is true."
}
precondition {
condition = var.private_endpoint.private_dns_zone_id != null
error_message = "private_endpoint.private_dns_zone_id must be provided when private_endpoint.enabled is true."
}
}
}Postcondition — checked after the resource is created, validates the real state:
resource "azurerm_storage_account" "this" {
# ...
lifecycle {
postcondition {
condition = self.min_tls_version == "TLS1_2"
error_message = "Storage account must enforce TLS 1.2. Check the provider version or resource arguments."
}
}
}Guidance:
- Use preconditions for cross-variable dependencies that cannot be expressed in a single
validationblock (e.g., "if X is enabled, Y must be set") - Use postconditions to assert that deployed resource state meets expectations — useful for compliance-critical defaults
- Keep conditions simple — complex conditions belong in locals
- Prefer preconditions over postconditions where possible (fail fast, before resources are created)
locals.tf Pattern
Use locals to construct names, derive values, or improve readability. Do not use locals to hide important logic.
locals {
# Construct a canonical name if the module applies naming conventions
resource_name = lower("${var.prefix}-${var.workload}-${var.environment}-${var.location}-${var.instance}")
# Merge caller tags with module-level defaults
tags = merge(
{
managed-by = "terraform"
module = "storage-account"
environment = var.environment
},
var.tags
)
# Derive a boolean from a nullable optional
enable_diagnostics = var.diagnostic_settings != null && var.diagnostic_settings.enabled
}Managed Identity Pattern
Always use managed identity over connection strings or keys where the resource supports it.
resource "azurerm_storage_account" "this" {
# ...
identity {
type = "SystemAssigned"
}
}
output "principal_id" {
description = "Principal ID of the system-assigned managed identity for RBAC assignments."
value = azurerm_storage_account.this.identity[0].principal_id
}For modules that need to configure RBAC, accept role_assignments as a variable:
variable "role_assignments" {
description = "List of RBAC role assignments to create on the resource."
type = list(object({
principal_id = string
role_definition_name = string
}))
default = []
}
resource "azurerm_role_assignment" "this" {
for_each = { for ra in var.role_assignments : ra.principal_id => ra }
scope = azurerm_storage_account.this.id
role_definition_name = each.value.role_definition_name
principal_id = each.value.principal_id
}Diagnostic Settings Pattern
Standard pattern for forwarding logs and metrics to a Log Analytics Workspace.
resource "azurerm_monitor_diagnostic_setting" "this" {
count = var.diagnostic_settings.enabled ? 1 : 0
name = "${azurerm_storage_account.this.name}-diagnostics"
target_resource_id = azurerm_storage_account.this.id
log_analytics_workspace_id = var.diagnostic_settings.log_analytics_workspace_id
enabled_log {
category_group = "allLogs"
}
metric {
category = "AllMetrics"
enabled = true
}
}Guidance:
- Make diagnostic settings optional (default off, enabled on request)
- Accept
log_analytics_workspace_idas a variable — do not create a workspace inside the module - Use
category_group = "allLogs"as the default — simpler than per-category configuration
Private Endpoint Pattern
Pass private endpoint configuration as an optional structured variable.
variable "private_endpoint" {
description = "Optional private endpoint configuration for the resource."
type = object({
enabled = bool
subnet_id = optional(string)
private_dns_zone_id = optional(string)
})
default = {
enabled = false
}
}
resource "azurerm_private_endpoint" "this" {
count = var.private_endpoint.enabled ? 1 : 0
name = "${azurerm_storage_account.this.name}-pe"
location = azurerm_storage_account.this.location
resource_group_name = azurerm_storage_account.this.resource_group_name
subnet_id = var.private_endpoint.subnet_id
private_service_connection {
name = "${azurerm_storage_account.this.name}-psc"
private_connection_resource_id = azurerm_storage_account.this.id
subresource_names = ["blob"]
is_manual_connection = false
}
dynamic "private_dns_zone_group" {
for_each = var.private_endpoint.private_dns_zone_id != null ? [1] : []
content {
name = "default"
private_dns_zone_ids = [var.private_endpoint.private_dns_zone_id]
}
}
}Guidance:
- Do not create the DNS zone inside the module — accept it as an ID
- Do not create the subnet inside the module — accept it as an ID
- Private endpoint is optional — default to disabled
Tagging Pattern
Always merge caller tags with module-level defaults.
variable "tags" {
description = "Additional tags to apply to all resources. Module-level defaults are merged with these."
type = map(string)
default = {}
}
locals {
tags = merge(
{
managed-by = "terraform"
},
var.tags
)
}Apply local.tags to every resource in the module.
Example Usage Block (for README)
Every module README should include an example that can be copied and run.
module "storage" {
source = "git::https://github.com/your-org/terraform-modules.git//modules/storage-account?ref=v1.0.0"
name = "stmyappproduksouth001"
resource_group_name = "rg-myapp-prod-uksouth-001"
location = "uksouth"
sku = "Standard"
tags = {
environment = "prod"
project = "myapp"
owner = "platform-team"
cost-center = "CC-12345"
}
diagnostic_settings = {
enabled = true
log_analytics_workspace_id = "/subscriptions/.../workspaces/law-myapp-prod"
retention_days = 30
}
private_endpoint = {
enabled = true
subnet_id = "/subscriptions/.../subnets/snet-pe-prod"
private_dns_zone_id = "/subscriptions/.../privateDnsZones/privatelink.blob.core.windows.net"
}
}moved Blocks
Use moved blocks when refactoring module internals to rename or restructure resources without breaking consumer state. Without a moved block, Terraform plans to destroy the old resource and create a new one — which is destructive.
Rename a resource within the same module:
moved {
from = azurerm_storage_account.storage
to = azurerm_storage_account.this
}Rename a module call (at the consumer level):
moved {
from = module.old_storage
to = module.storage
}Move a resource into a for_each structure:
moved {
from = azurerm_storage_account.this
to = azurerm_storage_account.this["primary"]
}Guidance:
- Always add a
movedblock when renaming a resource identifier in a module that has existing consumers - Place
movedblocks inmain.tfor a dedicatedmoved.tf— do not spread them across files movedblocks accumulate over time and can be removed after all consumers have applied the change (typically after 1–2 release cycles)- If the resource type changes (not just the identifier), a
movedblock cannot help — that is a breaking change requiring a MAJOR version bump - Document
movedblocks in the CHANGELOG under the version that introduces them
Anti-Patterns to Avoid
| Anti-pattern | Problem | Better approach |
|---|---|---|
| Wrapping a single resource with no added value | Just adds complexity | Use the resource directly |
| Exposing all provider arguments as variables | Creates a maintenance burden and confusing interface | Expose only what consumers need |
| Creating DNS zones or VNets inside the module | Tight coupling, unusable in existing environments | Accept IDs as variables |
Feature flag variables (enable_feature_x = true/false) |
Modules become implicit frameworks | Separate modules for distinct patterns |
count-based conditional resources with complex dependencies |
Creates hard-to-debug plan outputs | Use for_each with a map or null checks |
| Hardcoded names | Prevents reuse across environments | Use variables for all names |
| Nested modules for a simple pattern | Over-abstraction | Flat structure is easier to read and maintain |