| name | terraform-style-guide |
| description | Comprehensive guide for Terraform code style, formatting, and best practices based on HashiCorp's official standards and Azure Verified Modules (AVM) requirements. Use when writing or reviewing Terraform configurations, formatting code, organizing files and modules, establishing team conventions, managing version control, ensuring code quality and consistency across infrastructure projects, or developing Azure Verified Modules. |
Terraform Style Guide
Adopting and adhering to a style guide keeps your Terraform code legible, scalable, and maintainable. This guide is based on HashiCorp's official Terraform style conventions and best practices, enhanced with Azure Verified Modules (AVM) requirements for Azure-specific Terraform development.
Note on AVM Requirements: The Azure Verified Modules section provides requirements specific to Azure module development. While these requirements are mandatory for AVM certification, many of the patterns and practices have broader applicability to Terraform module development across all cloud providers and can be adopted to improve code quality, consistency, and maintainability in any Terraform project.
Table of Contents
- Code Style Fundamentals
- Code Formatting Standards
- File Organization
- Naming Conventions
- Resource Organization
- Variables and Outputs
- Local Values
- Provider Configuration and Aliasing
- Dynamic Resource Creation
- Version Control
- Workflow Standards
- Multi-Environment Management
- State and Secrets Management
- Testing and Policy
- AWS-Specific Requirements
- Azure Verified Modules (AVM) Requirements
Code Style Fundamentals
Core Principles
Always follow these fundamental practices:
- Execute
terraform fmtbefore committing code to version control - Execute
terraform validateto catch syntax and configuration errors - Use
#for comments (avoid//and/* */style comments) - Name resources with descriptive nouns using underscores, excluding the resource type
- Define dependent resources after their dependencies for better readability
- Include type and description for all variables
- Include descriptions for all outputs
- Use
countandfor_eachjudiciously with clear intent
Automation with Git Hooks
Consider using Git pre-commit hooks to automatically run terraform fmt and terraform validate:
#!/bin/bash
# .git/hooks/pre-commit
terraform fmt -recursive
terraform validate
Code Formatting Standards
Terraform has specific formatting conventions that the terraform fmt command automates.
Indentation
- Use two spaces per nesting level
- Never use tabs
resource "aws_instance" "example" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
tags = {
Name = "example-instance"
}
}
Alignment
- Align equals signs for consecutive single-line arguments at the same nesting level
- Separate different argument groups with blank lines
# Good - aligned equals signs
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
subnet_id = "subnet-12345678"
tags = {
Name = "web-server"
Environment = "production"
}
}
# Bad - unaligned
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
subnet_id = "subnet-12345678"
}
Block Organization
- Arguments precede blocks within a resource
- Separate with one blank line between arguments and blocks
- Meta-arguments come first, followed by standard arguments, then blocks
resource "aws_instance" "example" {
# Meta-arguments first
count = 3
# Standard arguments
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
# Blocks last
root_block_device {
volume_size = 20
}
}
Spacing
- Use single blank lines to separate logical groups of arguments
- Top-level blocks (resources, data sources, modules) require blank lines between them
- Do not use excessive blank lines
variable "instance_count" {
description = "Number of instances to create"
type = number
default = 1
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t2.micro"
}
File Organization
Standard File Structure
Organize your Terraform code into these standard files:
| File | Purpose |
|---|---|
terraform.tf |
Terraform and provider version requirements |
providers.tf |
Provider configurations |
main.tf |
Primary resources and data sources |
variables.tf |
Input variable declarations (alphabetical order) |
outputs.tf |
Output value declarations (alphabetical order) |
locals.tf |
Local value declarations |
Example terraform.tf:
terraform {
required_version = ">= 1.7"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.34.0"
}
}
}
Example providers.tf:
provider "aws" {
region = var.aws_region
default_tags {
tags = {
ManagedBy = "Terraform"
Project = "MyProject"
}
}
}
Naming Conventions
General Rules
- Use descriptive nouns and underscores to separate multiple words
- Exclude the resource type from the resource name (redundant)
- Use lowercase for all names
- Be specific and meaningful
Examples
# ❌ Bad - includes resource type, uses hyphens, mixed case
resource "aws_instance" "webAPI-aws-instance" {
# ...
}
# ✅ Good - descriptive noun, underscores, lowercase
resource "aws_instance" "web_api" {
# ...
}
# ❌ Bad - too generic
variable "name" {
type = string
}
# ✅ Good - specific and clear
variable "application_name" {
type = string
}
Variable Naming
Variables should clearly indicate their purpose:
variable "vpc_cidr_block" {
description = "CIDR block for the VPC"
type = string
}
variable "enable_dns_hostnames" {
description = "Enable DNS hostnames in the VPC"
type = bool
default = true
}
Resource Organization
Dependency Order
Define a data source before the resource that references it for better readability:
# Data source first
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"]
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"]
}
}
# Resource that uses it second
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = "t2.micro"
}
Parameter Order Within Resources
Follow this standard ordering for resource parameters:
countorfor_each(meta-arguments)- Resource-specific non-block parameters (alphabetically or logically grouped)
- Resource-specific block parameters
lifecycleblock (if needed)depends_on(if required, as last resort)
resource "aws_instance" "web" {
# 1. Meta-arguments
count = var.instance_count
# 2. Non-block parameters
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
subnet_id = aws_subnet.public.id
# 3. Block parameters
root_block_device {
volume_size = 20
volume_type = "gp3"
}
tags = {
Name = "web-${count.index}"
}
# 4. Lifecycle
lifecycle {
create_before_destroy = true
}
# 5. depends_on (avoid if possible)
# depends_on = [aws_iam_role_policy.example]
}
Variables and Outputs
Variable Declaration Standards
Every variable must include:
type- the data typedescription- clear explanation of purpose
Optional but recommended:
default- default value if applicablesensitive- mark as true for secretsvalidation- for uniquely restrictive requirements
variable "instance_type" {
description = "EC2 instance type for the web server"
type = string
default = "t2.micro"
validation {
condition = contains(["t2.micro", "t2.small", "t2.medium"], var.instance_type)
error_message = "Instance type must be t2.micro, t2.small, or t2.medium."
}
}
variable "database_password" {
description = "Password for the database admin user"
type = string
sensitive = true
}
variable "availability_zones" {
description = "List of availability zones for resource placement"
type = list(string)
}
variable "tags" {
description = "Common tags to apply to all resources"
type = map(string)
default = {}
}
Output Declaration Standards
Every output must include:
description- clear explanation of the value
Optional attributes:
sensitive- mark as true to hide from console outputdepends_on- explicit dependencies if needed
output "instance_id" {
description = "ID of the EC2 instance"
value = aws_instance.web.id
}
output "instance_public_ip" {
description = "Public IP address of the EC2 instance"
value = aws_instance.web.public_ip
}
output "database_password" {
description = "Database administrator password"
value = aws_db_instance.main.password
sensitive = true
}
Variable Files Organization
Organize variables alphabetically in variables.tf and use .tfvars files for environment-specific values:
# terraform.tfvars (or dev.tfvars, prod.tfvars)
instance_type = "t2.micro"
instance_count = 3
availability_zones = ["us-west-2a", "us-west-2b"]
Local Values
Usage Guidelines
Use local values sparingly to avoid unnecessary complexity. Locals are appropriate when:
- Avoiding repetition of complex expressions
- Giving meaningful names to intermediate values
- Computing values used multiple times
locals {
# Good use case - computing a reusable value
common_tags = merge(
var.tags,
{
Environment = var.environment
ManagedBy = "Terraform"
Project = var.project_name
}
)
# Good use case - naming a complex expression
vpc_id = var.create_vpc ? aws_vpc.main[0].id : data.aws_vpc.existing[0].id
}
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
tags = local.common_tags
}
Anti-patterns to Avoid
# ❌ Bad - unnecessary local for a simple reference
locals {
instance_type = var.instance_type
}
# ✅ Good - use the variable directly
resource "aws_instance" "web" {
instance_type = var.instance_type
}
Provider Configuration and Aliasing
Default Provider First
Always define a default provider configuration first, then aliases:
# Default provider
provider "aws" {
region = "us-west-2"
}
# Aliased provider for another region
provider "aws" {
alias = "east"
region = "us-east-1"
}
# Using the aliased provider
resource "aws_instance" "east_web" {
provider = aws.east
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
}
Module Provider Configuration
For modules that use multiple providers, specify via the providers meta-argument:
module "vpc_replication" {
source = "./modules/vpc"
providers = {
aws.primary = aws
aws.secondary = aws.east
}
}
Dynamic Resource Creation
count vs for_each
Choose the appropriate meta-argument based on your use case:
Use for_each when:
- Resources need distinct argument values
- You want to reference resources by key instead of index
- Resources are based on a map or set
- Preference for_each over count
Use count when:
- Conditional resource creation (0 or 1)
Avoid count for:
- Simple numeric repetition (use
for_eachwith a set or map instead)
for_each Examples
# Using for_each with a map
variable "instances" {
type = map(object({
instance_type = string
ami = string
}))
default = {
web = {
instance_type = "t2.micro"
ami = "ami-0c55b159cbfafe1f0"
}
api = {
instance_type = "t2.small"
ami = "ami-0c55b159cbfafe1f0"
}
}
}
resource "aws_instance" "servers" {
for_each = var.instances
ami = each.value.ami
instance_type = each.value.instance_type
tags = {
Name = each.key
}
}
# Reference: aws_instance.servers["web"].id
# Using for_each with a set
variable "subnet_cidrs" {
type = set(string)
default = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
}
resource "aws_subnet" "private" {
for_each = var.subnet_cidrs
vpc_id = aws_vpc.main.id
cidr_block = each.value
tags = {
Name = "private-${each.key}"
}
}
count Examples
# Conditional resource creation
variable "enable_monitoring" {
type = bool
default = false
}
resource "aws_cloudwatch_metric_alarm" "cpu" {
count = var.enable_monitoring ? 1 : 0
alarm_name = "high-cpu-usage"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2
metric_name = "CPUUtilization"
namespace = "AWS/EC2"
period = 300
statistic = "Average"
threshold = 80
}
# Reference (when created): aws_cloudwatch_metric_alarm.cpu[0].id
Anti-pattern: count for Numeric Repetition
❌ Avoid this pattern - Using count for simple numeric repetition:
# BAD: Don't use count for numeric repetition
variable "instance_count" {
type = number
default = 3
}
resource "aws_instance" "web" {
count = var.instance_count
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
tags = {
Name = "web-${count.index}"
}
}
✅ Better approach - Use for_each with a set instead:
# GOOD: Use for_each for multiple similar resources
variable "instance_names" {
type = set(string)
default = ["web-1", "web-2", "web-3"]
}
resource "aws_instance" "web" {
for_each = var.instance_names
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
tags = {
Name = each.key
}
}
# Reference: aws_instance.web["web-1"].id
Why? Using for_each provides stable resource addresses that don't change when you add or remove instances from the middle of the list.
Version Control
.gitignore Configuration
Never commit to version control:
- State files (
terraform.tfstate,terraform.tfstate.backup) - Lock info files (
.terraform.tfstate.lock.info) .terraformdirectory (provider plugins and modules)- Saved plan files (
*.tfplan,plan.out) .tfvarsfiles containing sensitive data
Always commit:
- All
.tfconfiguration files .terraform.lock.hcl(dependency lock file).gitignorefile- README and documentation files
Workflow Standards
Version Pinning
Always pin versions explicitly to ensure reproducible deployments:
terraform {
required_version = ">= 1.7"
required_providers {
aws = {
source = "hashicorp/aws"
version = "5.34.0" # Pin to exact version for stability
}
random = {
source = "hashicorp/random"
version = "~> 3.6" # Allow patch updates only
}
}
}
Version constraint operators:
= 1.0.0- Exact version only>= 1.0.0- Greater than or equal to~> 1.0- Allow rightmost version component to increment (1.0, 1.1, but not 2.0)>= 1.0, < 2.0- Version range
Module Repository Naming
Use the convention: terraform-<PROVIDER>-<NAME>
Examples:
terraform-aws-vpcterraform-azurerm-virtual-networkterraform-google-kubernetes-engine
Repository Strategy
Three common approaches:
1. Separate Module Repositories (Recommended)
- Each module in its own repository
- Independent versioning and release cycles
- Clear ownership boundaries
2. Logical Infrastructure Grouping
- Group related resources per repository
- Example:
infra-networking,infra-compute,infra-databases - Easier to manage related changes together
3. Monorepo
- All infrastructure code in one repository
- Centralized management
- Requires careful CI/CD targeting
Branching Strategy
Adopt GitHub Flow for simplicity:
- Create short-lived feature branches from main
- Submit pull requests for review
- Enable speculative plans in HCP Terraform (automatic on PRs)
- Merge to main after approval
- Delete feature branches after merge
# Create feature branch
git checkout -b feature/add-monitoring
# Make changes and commit
git add .
git commit -m "Add CloudWatch monitoring for EC2 instances"
# Push and create PR
git push origin feature/add-monitoring
Multi-Environment Management
Workspace-Based Approach (Recommended with HCP Terraform)
Use separate workspaces for each environment:
# Development workspace: app-dev
# Staging workspace: app-staging
# Production workspace: app-prod
Terraform Cloud/HCP Terraform automatically manages state per workspace.
State and Secrets Management
Use HCP Terraform for state storage
State File Security
Never share full state files directly. State files contain sensitive information.
Alternatives for sharing data:
- tfe_outputs data source (HCP Terraform):
data "tfe_outputs" "vpc" {
organization = "my-org"
workspace = "networking-prod"
}
resource "aws_instance" "web" {
subnet_id = data.tfe_outputs.vpc.values.private_subnet_ids[0]
}
- Provider-specific data sources:
data "aws_vpc" "main" {
tags = {
Name = "main-vpc"
}
}
resource "aws_subnet" "app" {
vpc_id = data.aws_vpc.main.id
}
Secrets Management
Protect credentials through:
Dynamic Provider Credentials (HCP Terraform)
- OIDC-based authentication
- No long-lived credentials in configuration
HashiCorp Vault Integration:
data "vault_generic_secret" "database" {
path = "secret/database"
}
resource "aws_db_instance" "main" {
username = data.vault_generic_secret.database.data["username"]
password = data.vault_generic_secret.database.data["password"]
}
- Environment Variables:
variable "database_password" {
description = "Database password (set via TF_VAR_database_password)"
type = string
sensitive = true
}
export TF_VAR_database_password="secure-password"
terraform apply
Testing and Policy
Module Testing
Write tests for modules using Terraform's native testing framework:
# tests/vpc.tftest.hcl
run "valid_vpc_cidr" {
command = plan
variables {
vpc_cidr = "10.0.0.0/16"
}
assert {
condition = aws_vpc.main.cidr_block == "10.0.0.0/16"
error_message = "VPC CIDR block did not match expected value"
}
}
run "vpc_enables_dns" {
command = plan
assert {
condition = aws_vpc.main.enable_dns_hostnames == true
error_message = "VPC should have DNS hostnames enabled"
}
}
Run tests:
terraform test
Common Testing Scenarios
- Validation Tests - Verify variable constraints
- Plan Tests - Check expected resources will be created
- Apply Tests - Test actual resource creation (in isolated environment)
- Integration Tests - Verify resources work together correctly
Summary Checklist
Use this checklist for code reviews:
- Code formatted with
terraform fmt - Configuration validated with
terraform validate - Files organized according to standard structure
- All variables have type and description
- All outputs have descriptions
- Resource names use descriptive nouns with underscores
- Resources ordered with dependencies first
- Version constraints pinned explicitly
- Sensitive values marked with
sensitive = true -
.gitignoreexcludes state files and secrets - Tests written for modules
- Policy requirements satisfied
- Code reviewed by teammate
AWS-Specific Requirements
Important: The following requirements are specific to AWS resource deployments and should be applied to all AWS Terraform configurations for consistency, cost tracking, and governance.
Mandatory Resource Tagging
Severity: MUST | Requirement: AWS-TAG-001
All AWS resources that support tags MUST include at minimum an Application tag to identify the application or service the resource belongs to. This is critical for:
- Cost allocation and tracking
- Resource governance and management
- Security and compliance auditing
- Automated resource lifecycle management
Required Tag Implementation
Every taggable AWS resource MUST include:
tags = {
Application = var.application_name # MANDATORY
# Additional tags as needed
Environment = var.environment
ManagedBy = "Terraform"
Owner = var.owner_email
CostCenter = var.cost_center
}
Provider-Level Default Tags
Best Practice: Configure default tags at the provider level to ensure all resources automatically inherit mandatory tags:
# providers.tf
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Application = var.application_name # MANDATORY
Environment = var.environment
ManagedBy = "Terraform"
Workspace = terraform.workspace
Repository = var.repository_url
}
}
}
Variable Definition for Application Tag
Always define the application name variable:
variable "application_name" {
description = "Name of the application this infrastructure supports (REQUIRED for all resources)"
type = string
validation {
condition = length(var.application_name) > 0
error_message = "Application name is required and cannot be empty."
}
}
Merging Tags with Local Values
For complex tagging scenarios, use local values to manage tag inheritance:
locals {
# Mandatory tags that must be present on all resources
mandatory_tags = {
Application = var.application_name
}
# Common tags for all resources
common_tags = merge(
local.mandatory_tags,
{
Environment = var.environment
ManagedBy = "Terraform"
CreatedDate = timestamp()
}
)
# Merge with additional tags passed as variables
all_tags = merge(
local.common_tags,
var.additional_tags
)
}
# Usage in resources
resource "aws_instance" "example" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
tags = merge(
local.all_tags,
{
Name = "example-instance"
Type = "compute"
}
)
}
Tag Validation and Compliance
Implement validation to ensure required tags are present:
variable "tags" {
description = "Map of tags to apply to resources"
type = map(string)
validation {
condition = contains(keys(var.tags), "Application")
error_message = "The 'Application' tag is mandatory and must be included in the tags map."
}
}
Resources That Don't Support Tags
Some AWS resources don't support tags directly. For these resources, document the application association in the resource name or description:
resource "aws_iam_policy_document" "example" {
# IAM policy documents don't support tags
# Include application name in the statement sid for traceability
statement {
sid = "${var.application_name}_S3Access"
# ...
}
}
resource "aws_iam_role" "example" {
name = "${var.application_name}-role" # Include app name in resource name
tags = {
Application = var.application_name # IAM roles do support tags
}
}
AWS Provider Configuration
Severity: SHOULD | Requirement: AWS-PROV-001
Provider Version Constraints
AWS provider configurations SHOULD follow these guidelines:
terraform {
required_version = ">= 1.7"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0" # Use pessimistic constraint for stability
}
}
}
Multi-Region Configuration
For multi-region deployments, use provider aliases with clear naming:
# Primary region (default provider)
provider "aws" {
region = var.primary_region
default_tags {
tags = {
Application = var.application_name
Region = var.primary_region
}
}
}
# Secondary regions with aliases
provider "aws" {
alias = "us_east_1"
region = "us-east-1"
default_tags {
tags = {
Application = var.application_name
Region = "us-east-1"
}
}
}
provider "aws" {
alias = "eu_west_1"
region = "eu-west-1"
default_tags {
tags = {
Application = var.application_name
Region = "eu-west-1"
}
}
}
Assume Role Configuration
For cross-account deployments, configure role assumption:
provider "aws" {
region = var.aws_region
assume_role {
role_arn = var.assume_role_arn
session_name = "${var.application_name}-terraform"
}
default_tags {
tags = {
Application = var.application_name
Account = var.target_account_id
}
}
}
AWS Resource Naming
Severity: SHOULD | Requirement: AWS-NAME-001
AWS resource names SHOULD follow these conventions for consistency and clarity:
Naming Pattern
Use the pattern: {application}-{environment}-{resource-type}-{identifier}
locals {
name_prefix = "${var.application_name}-${var.environment}"
}
resource "aws_s3_bucket" "data" {
bucket = "${local.name_prefix}-data-bucket"
tags = merge(
local.common_tags,
{
Name = "${local.name_prefix}-data-bucket"
}
)
}
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
tags = merge(
local.common_tags,
{
Name = "${local.name_prefix}-web-server"
}
)
}
resource "aws_rds_instance" "database" {
identifier = "${local.name_prefix}-postgres-db"
# ...
tags = merge(
local.common_tags,
{
Name = "${local.name_prefix}-postgres-db"
}
)
}
DNS-Compatible Names
For resources that require DNS-compatible names (S3 buckets, CloudFront distributions), ensure names:
- Use only lowercase letters, numbers, and hyphens
- Don't start or end with hyphens
- Are between 3 and 63 characters long
locals {
# Ensure DNS-compatible naming
dns_safe_name = lower(replace(var.application_name, "_", "-"))
bucket_name = "${local.dns_safe_name}-${var.environment}-${random_id.bucket.hex}"
}
resource "random_id" "bucket" {
byte_length = 4
}
resource "aws_s3_bucket" "example" {
bucket = local.bucket_name # Guaranteed to be DNS-compatible
tags = merge(
local.common_tags,
{
Name = local.bucket_name
}
)
}
Resource Name vs Tag Name
Always include both the resource argument name and a Name tag for consistency:
resource "aws_security_group" "web" {
name = "${local.name_prefix}-web-sg" # Resource argument
description = "Security group for ${var.application_name} web servers"
tags = merge(
local.common_tags,
{
Name = "${local.name_prefix}-web-sg" # Name tag matches resource name
}
)
}
AWS-Specific Testing Considerations
When testing AWS infrastructure:
- Use Separate AWS Accounts for testing when possible
- Include the Application tag in test configurations
- Test tag inheritance from provider default_tags
- Validate naming conventions meet AWS service requirements
# tests/aws_tags.tftest.hcl
run "verify_application_tag" {
command = plan
variables {
application_name = "test-app"
}
assert {
condition = aws_instance.example.tags["Application"] == "test-app"
error_message = "Application tag must be set on all resources"
}
}
run "verify_name_pattern" {
command = plan
variables {
application_name = "myapp"
environment = "dev"
}
assert {
condition = can(regex("^myapp-dev-", aws_instance.example.tags["Name"]))
error_message = "Resource names must follow the {app}-{env}-{type} pattern"
}
}
AWS Compliance Checklist
Add these items to your review checklist for AWS deployments:
- All taggable resources include the mandatory
Applicationtag - Provider configuration includes
default_tagswithApplicationtag - Application name variable is defined with validation
- Resource names follow the
{application}-{environment}-{resource-type}pattern - DNS-required resource names are validated for compliance
- Multi-region deployments use clear provider aliases
- Cross-account access uses assume_role with proper session naming
- Both resource names and Name tags are set consistently
- Tag inheritance from provider default_tags is working correctly
- Test configurations include Application tag validation
Azure Verified Modules (AVM) Requirements
Important: The following requirements are mandatory for Azure Verified Modules but represent best practices that can enhance Terraform module development across any cloud provider.
Module Cross-Referencing
Severity: MUST | Requirement: TFFR1
When building Resource or Pattern modules, module owners MAY cross-reference other modules. However:
- Modules MUST be referenced using HashiCorp Terraform registry reference to a pinned version
- Example:
source = "Azure/xxx/azurerm"withversion = "1.2.3"
- Example:
- Modules MUST NOT use git references (e.g.,
git::https://xxx.yyy/xxx.gitorgithub.com/xxx/yyy) - Modules MUST NOT contain references to non-AVM modules
Broader Applicability: Always use registry references with pinned versions for any module to ensure reproducibility and version control.
Azure Provider Requirements
Severity: MUST | Requirement: TFFR3
For Azure Verified Modules, authors MUST only use the following Azure providers:
| Provider | Min Version | Max Version |
|---|---|---|
| azapi | >= 2.0 | < 3.0 |
| azurerm | >= 4.0 | < 5.0 |
Requirements:
- Authors MAY select either Azurerm, Azapi, or both providers
- MUST use
required_providersblock to enforce provider versions - SHOULD use pessimistic version constraint operator (
~>)
Example:
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
azapi = {
source = "Azure/azapi"
version = "~> 2.0"
}
}
}
Broader Applicability: Always specify provider versions with appropriate constraints for any cloud provider to ensure compatibility.
AVM Code Style Standards
Lower snake_casing
Severity: MUST | Requirement: TFNFR4
MUST use lower snake_casing for:
- Locals
- Variables
- Outputs
- Resources (symbolic names)
- Modules (symbolic names)
Example: snake_casing_example
Resource & Data Source Ordering
Severity: SHOULD | Requirement: TFNFR6
- Resources that are depended on SHOULD come first
- Resources with dependencies SHOULD be defined close to each other
Count & for_each Usage
Severity: MUST | Requirement: TFNFR7
- Use
countfor conditional resource creation - MUST use
map(xxx)orset(xxx)as resource'sfor_eachcollection - The map's key or set's element MUST be static literals
Good Example:
resource "azurerm_subnet" "pair" {
for_each = var.subnet_map # map(string)
name = "${each.value}-pair"
resource_group_name = azurerm_resource_group.example.name
virtual_network_name = azurerm_virtual_network.example.name
address_prefixes = ["10.0.1.0/24"]
}
Broader Applicability: Using typed collections with for_each ensures predictable behavior across all providers.
Resource & Data Block Internal Ordering
Severity: SHOULD | Requirement: TFNFR8
Order within resource/data blocks:
Meta-arguments (top):
providercountfor_each
Arguments/blocks (middle, alphabetical):
- Required arguments
- Optional arguments
- Required nested blocks
- Optional nested blocks
Meta-arguments (bottom):
depends_onlifecycle(with sub-order:create_before_destroy,ignore_changes,prevent_destroy)
Separate sections with blank lines.
Module Block Ordering
Severity: SHOULD | Requirement: TFNFR9
Order within module blocks:
Top meta-arguments:
sourceversioncountfor_each
Arguments (alphabetical):
- Required arguments
- Optional arguments
Bottom meta-arguments:
depends_onproviders
Lifecycle ignore_changes Syntax
Severity: MUST | Requirement: TFNFR10
The ignore_changes attribute MUST NOT be enclosed in double quotes.
Good:
lifecycle {
ignore_changes = [tags]
}
Bad:
lifecycle {
ignore_changes = ["tags"]
}
Null Comparison for Conditional Creation
Severity: SHOULD | Requirement: TFNFR11
For parameters requiring conditional resource creation, wrap with object type to avoid "known after apply" issues during plan stage.
Recommended:
variable "security_group" {
type = object({
id = string
})
default = null
}
Broader Applicability: This pattern prevents plan-time issues across all providers when using conditional resources.
Dynamic Blocks for Optional Nested Objects
Severity: MUST | Requirement: TFNFR12
Nested blocks under conditions MUST use this pattern:
dynamic "identity" {
for_each = <condition> ? [<some_item>] : []
content {
# block content
}
}
Broader Applicability: This is the standard Terraform pattern for conditional nested blocks.
Default Values with coalesce/try
Severity: SHOULD | Requirement: TFNFR13
Good:
coalesce(var.new_network_security_group_name, "${var.subnet_name}-nsg")
Bad:
var.new_network_security_group_name == null ? "${var.subnet_name}-nsg" : var.new_network_security_group_name
Broader Applicability: coalesce() and try() functions provide cleaner, more readable default value handling.
Provider Declarations in Modules
Severity: MUST | Requirement: TFNFR27
providerMUST NOT be declared in modules (except forconfiguration_aliases)providerblocks in modules MUST only usealias- Provider configurations SHOULD be passed in by module users
Broader Applicability: This is a universal best practice for reusable Terraform modules.
AVM Variable Requirements
Not Allowed Variables
Severity: MUST | Requirement: TFNFR14
Module owners MUST NOT add variables like enabled or module_depends_on to control entire module operation. Boolean feature toggles for specific resources are acceptable.
Variable Definition Order
Severity: SHOULD | Requirement: TFNFR15
Variables SHOULD follow this order:
- All required fields (alphabetical)
- All optional fields (alphabetical)
Variable Naming Rules
Severity: SHOULD | Requirement: TFNFR16
- Follow HashiCorp's naming rules
- Feature switches SHOULD use positive statements:
xxx_enabledinstead ofxxx_disabled
Variables with Descriptions
Severity: SHOULD | Requirement: TFNFR17
descriptionSHOULD precisely describe the parameter's purpose and expected data type- Target audience is module users, not developers
- For
objecttypes, use HEREDOC format
Variable and output descriptions MAY span multiple lines using HEREDOC format with embedded markdown for examples.
Variables with Types
Severity: MUST | Requirement: TFNFR18
typeMUST be defined for every variabletypeSHOULD be as precise as possibleanyMAY only be used with adequate reasons- Use
boolinstead ofstring/numberfor true/false values - Use concrete
objectinstead ofmap(any)
Broader Applicability: Precise typing prevents errors and improves documentation across all Terraform code.
Sensitive Data Variables
Severity: SHOULD | Requirement: TFNFR19
If a variable's type is object and contains sensitive fields, the entire variable SHOULD be sensitive = true, or extract sensitive fields into separate variables.
Non-Nullable Defaults for Collections
Severity: SHOULD | Requirement: TFNFR20
Nullable SHOULD be set to false for collection values (sets, maps, lists) when using them in loops. For scalar values, null may have semantic meaning.
Discourage Nullability by Default
Severity: MUST | Requirement: TFNFR21
nullable = true MUST be avoided unless there's a specific semantic need for null values.
Avoid sensitive = false
Severity: MUST | Requirement: TFNFR22
sensitive = false MUST be avoided (this is the default).
Sensitive Default Value Conditions
Severity: MUST | Requirement: TFNFR23
A default value MUST NOT be set for sensitive inputs (e.g., default passwords).
Handling Deprecated Variables
Severity: MUST | Requirement: TFNFR24
- Move deprecated variables to
deprecated_variables.tf - Annotate with
DEPRECATEDat the beginning of description - Declare the replacement's name
- Clean up during major version releases
Broader Applicability: Clear deprecation management improves user experience for any module.
AVM Output Requirements
Additional Terraform Outputs
Severity: SHOULD | Requirement: TFFR2
Authors SHOULD NOT output entire resource objects as these may contain sensitive data and the schema can change with API or provider versions.
Best Practices:
- Output computed attributes of resources as discrete outputs (anti-corruption layer pattern)
- SHOULD NOT output values that are already inputs (except
name) - Use
sensitive = truefor sensitive attributes - For resources deployed with
for_each, output computed attributes in a map structure
Examples:
# Single resource computed attribute
output "foo" {
description = "MyResource foo attribute"
value = azurerm_resource_myresource.foo
}
# for_each resources
output "childresource_foos" {
description = "MyResource children's foo attributes"
value = {
for key, value in azurerm_resource_mychildresource : key => value.foo
}
}
# Sensitive output
output "bar" {
description = "MyResource bar attribute"
value = azurerm_resource_myresource.bar
sensitive = true
}
Broader Applicability: The anti-corruption layer pattern protects consumers from provider API changes.
Sensitive Data Outputs
Severity: MUST | Requirement: TFNFR29
Outputs containing confidential data MUST be declared with sensitive = true.
Handling Deprecated Outputs
Severity: MUST | Requirement: TFNFR30
- Move deprecated outputs to
deprecated_outputs.tf - Define new outputs in
outputs.tf - Clean up during major version releases
AVM Local Values Standards
locals.tf Organization
Severity: MAY | Requirement: TFNFR31
locals.tfSHOULD only containlocalsblocks- MAY declare
localsblocks next to resources for advanced scenarios
Alphabetical Local Arrangement
Severity: MUST | Requirement: TFNFR32
Expressions in locals blocks MUST be arranged alphabetically.
Precise Local Types
Severity: SHOULD | Requirement: TFNFR33
Use precise types (e.g., number for age, not string).
Broader Applicability: Type precision improves code clarity and catches errors early.
AVM Terraform Configuration Requirements
Terraform Version Requirements
Severity: MUST | Requirement: TFNFR25
terraform.tf requirements:
- MUST contain only one
terraformblock - First line MUST define
required_version - MUST include minimum version constraint
- MUST include maximum major version constraint
- SHOULD use
~> #.#or>= #.#.#, < #.#.#format
Example:
terraform {
required_version = "~> 1.6"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
Broader Applicability: Version constraints prevent compatibility issues across all Terraform projects.
Providers in required_providers
Severity: MUST | Requirement: TFNFR26
terraformblock MUST containrequired_providersblock- Each provider MUST specify
sourceandversion - Providers SHOULD be sorted alphabetically
- Only include directly required providers
sourceMUST be in formatnamespace/nameversionMUST include minimum and maximum major version constraints- SHOULD use
~> #.#or>= #.#.#, < #.#.#format
AVM Testing Requirements
Test Tooling
Severity: MUST | Requirement: TFNFR5
Required testing tools for AVM:
- Terraform (
terraform validate/fmt/test) - terrafmt
- trivy
- tflint (with azurerm ruleset)
- Go (optional for custom tests)
Broader Applicability: These tools provide comprehensive quality checks for any Terraform code.
Test Provider Configuration
Severity: SHOULD | Requirement: TFNFR36
For robust testing, prevent_deletion_if_contains_resources SHOULD be explicitly set to false in test provider configurations.
AVM Documentation Requirements
Module Documentation Generation
Severity: MUST | Requirement: TFNFR2
- Documentation MUST be automatically generated via Terraform Docs
- A
.terraform-docs.ymlfile MUST be present in the module root
Broader Applicability: Automated documentation ensures consistency and reduces maintenance burden.
Breaking Changes & Feature Management
Using Feature Toggles
Severity: MUST | Requirement: TFNFR34
New resources added in minor/patch versions MUST have a toggle variable to avoid creation by default:
variable "create_route_table" {
type = bool
default = false
nullable = false
}
resource "azurerm_route_table" "this" {
count = var.create_route_table ? 1 : 0
# ...
}
Broader Applicability: Feature toggles allow backward-compatible module evolution.
Reviewing Potential Breaking Changes
Severity: MUST | Requirement: TFNFR35
Breaking changes requiring caution:
Resource blocks:
- Adding new resource without conditional creation
- Adding arguments with non-default values
- Adding nested blocks without
dynamic - Renaming resources without
movedblocks - Changing
counttofor_eachor vice versa
Variable/Output blocks:
- Deleting/renaming variables
- Changing variable
type - Changing variable
defaultvalues - Changing
nullableto false - Changing
sensitivefrom false to true - Adding variables without
default - Deleting outputs
- Changing output
value - Changing output
sensitivevalue
Broader Applicability: Understanding breaking changes is crucial for maintaining any public Terraform module.
AVM Contribution Standards
GitHub Repository Branch Protection
Severity: MUST | Requirement: TFNFR3
Module owners MUST set branch protection policies on the default branch (typically main):
- Require Pull Request before merging
- Require approval of most recent reviewable push
- Dismiss stale PR approvals when new commits are pushed
- Require linear history
- Prevent force pushes
- Not allow deletions
- Require CODEOWNERS review
- No bypassing settings allowed
- Enforce for administrators
Broader Applicability: These protections ensure code quality for any collaborative project.
AVM Compliance Checklist
For Azure Verified Modules, add these items to your review checklist:
- Module cross-references use registry sources with pinned versions
- Azure providers (azurerm/azapi) versions meet AVM requirements
- All names use lower snake_casing
- Resources ordered with dependencies first
-
for_eachusesmap()orset()with static keys - Resource/data/module blocks follow proper internal ordering
-
ignore_changesnot quoted - Dynamic blocks used for conditional nested objects
- No
enabledormodule_depends_onvariables - Variables ordered: required (alphabetical) then optional (alphabetical)
- All variables have precise types (avoid
any) - Collections have
nullable = false - No
sensitive = falsedeclarations - No default values for sensitive inputs
- Deprecated variables moved to
deprecated_variables.tf - Outputs use anti-corruption layer pattern (discrete attributes)
- Sensitive outputs marked
sensitive = true - Deprecated outputs moved to
deprecated_outputs.tf - Locals arranged alphabetically
-
terraform.tfhas version constraints (~>format) -
required_providersblock present with all providers - No
providerdeclarations in module (except aliases) -
.terraform-docs.ymlpresent - New resources have feature toggles
- CODEOWNERS file present
Summary
This style guide combines HashiCorp's official Terraform conventions with cloud-specific requirements to provide comprehensive guidance for:
- General Terraform Development: Core formatting, naming, and organizational standards
- Module Development: Best practices for creating reusable, maintainable modules
- AWS-Specific Requirements: Mandatory tagging and naming conventions for AWS resources
- Azure-Specific Modules: Mandatory requirements for AVM certification
- Cross-Cloud Applicability: Patterns and practices beneficial for all cloud providers
By following these guidelines, you'll create Terraform code that is:
- Consistent: Predictable structure and formatting
- Maintainable: Easy to update and extend
- Scalable: Supports growth in complexity
- Reliable: Tested and validated
- Collaborative: Easy for teams to work with
Last Updated: November 15, 2024 Based on: HashiCorp Terraform Style Conventions, AWS Best Practices & Azure Verified Modules Requirements