Instruction file imported from franklesniak/test-ignore-me (
.github/instructions/terraform.instructions.md). Copyright stays with the author.
Terraform Writing Style
Version: 2.5.20260623.0
Metadata
- Status: Active
- Owner: Repository Maintainers
- Last Updated: 2026-06-23
- Scope: Terraform coding standards for all
.tf,.tfvars,.tftest.hcl,.tf.json,.tftpl, and.tfbackendfiles in this repository — style, formatting, naming, file organization, variable and output design, resource configuration, module design, state management, cross-stack data sharing, provider management, security, testing, and documentation.
Keywords
The key words MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL are defined in RFC 2119. Requirements apply when their scope is present (e.g., module rules apply only when modules exist).
Quick Reference Checklist
This checklist provides a quick reference for both human developers and LLMs (like GitHub Copilot) to follow the Terraform style guidelines. Each item includes a scope tag indicating applicability:
- [All] — Applies to all Terraform files
- [Module] — Applies when developing reusable modules
- [Root] — Applies to root configurations (deployments)
- [Test] — Applies to test files (
.tftest.hcl)
Scope reminder: Items tagged [Module], [Root], or [Test] are mandatory when those constructs are present. If the construct does not exist in your repo, the requirement is not yet applicable.
Formatting and Style (Quick Reference)
- [All] Code MUST pass
terraform fmtwithout modifications - [All] Code MUST use 2 spaces for indentation, never tabs
- [All] Files MUST use UTF-8 encoding
- [All] Files MUST end with a single newline
- [All] Lines SHOULD NOT exceed 120 characters except for long strings or URLs
- [All] Blank lines MUST be completely empty (no whitespace)
- [All] Comments MUST use
#for single-line;/* */MAY be used for multi-line
Naming Conventions (Quick Reference)
- [All] Resources MUST use
snake_casenames - [All] Variables MUST use
snake_casewith descriptive names - [All] Outputs MUST use
snake_casematching resource attribute patterns - [Module] Module directory names MUST use hyphen-separated lowercase words
- [All] Data sources MUST be prefixed with purpose when multiple exist
- [All] Locals MUST use
snake_casewith descriptive names - [All] Boolean variables SHOULD use
enable_*,is_*, orhas_*prefixes - [All] Globally unique resource names SHOULD include random suffixes or organization prefixes
File Organization (Quick Reference)
- [All] Every Terraform directory MUST have a
versions.tffile - [All] Input variables MUST be in
variables.tf - [All] Outputs MUST be in
outputs.tf - [Root] Root modules MUST have a
providers.tffile - [Root] Root modules MUST have a
backend.tfor backend configuration - [Module] Modules MUST include a
README.md - [Module] Modules SHOULD include
examples/directory - [Module] Modules SHOULD include
tests/directory - [All] Template files SHOULD use
.tftplextension - [All] Template files SHOULD be placed in a
templates/subdirectory - [Root] Large root modules MAY split resources into domain-specific files
Variable and Output Design (Quick Reference)
- [All] Variables MUST include a
description - [All] Variables MUST include explicit
typeconstraint - [All] Optional variables MUST have a
defaultvalue - [All] Sensitive variables MUST be marked with
sensitive = true - [All] Variables with constrained values SHOULD use
validationblocks - [All] Outputs MUST include a
description - [All] Sensitive outputs MUST be marked with
sensitive = true - [All] Variables SHOULD explicitly set
nullableto document null-handling behavior - [All]
try()SHOULD be used for defensive access to attributes that may not exist
Continuous Validation (Quick Reference)
- [All]
checkblocks MAY be used for continuous validation - [All] Terraform pre-commit hooks SHOULD avoid shell assumptions for native Windows/PowerShell contributors
Resource Configuration (Quick Reference)
- [All] Meta-arguments MUST appear first in resource blocks
- [All] Required arguments MUST appear before optional arguments
- [All] Nested blocks MUST appear last in resource blocks
- [All] Resources MUST include required tags
- [Root] Provider-level default tags SHOULD be configured
- [All] Local values SHOULD be used for computed or merged tags
- [All]
preconditionblocks SHOULD validate assumptions before resource creation - [All]
postconditionblocks SHOULD validate resource state after creation - [All]
depends_onSHOULD be avoided unless dependencies are not inferable - [All]
for_eachSHOULD be preferred overcountfor collections - [All] Dynamic blocks SHOULD be used sparingly
- [All]
prevent_destroySHOULD be used for critical resources - [All]
ignore_changesSHOULD be used for attributes managed outside Terraform - [All] Custom
timeoutsblocks MAY be used for long-running resource operations
Module Design (Quick Reference)
- [Module] Modules MUST have a single, well-defined responsibility
- [Module] Modules MUST specify required Terraform and provider versions
- [Module] Module inputs MUST use consistent naming across modules
- [Module] Required module variables SHOULD be minimized
- [Module] Complex inputs SHOULD use object types with documented structure
- [Module] Modules SHOULD expose only necessary outputs
- [Module] Published modules MUST use semantic versioning
- [Module] Modules accepting multiple provider configurations MUST use
configuration_aliases - [All] Module-level
depends_onSHOULD be avoided unless implicit dependencies are insufficient
Refactoring (Quick Reference)
- [All] Resource renames MUST use
movedblocks instead of manual state commands - [All] Existing infrastructure imports SHOULD use
importblocks instead of CLI commands - [All] Resources removed from management SHOULD use
removedblocks - [All] Direct state manipulation commands SHOULD be avoided
State Management (Quick Reference)
- [Root] Root modules MUST configure a remote backend
- [Root] State files MUST be encrypted at rest
- [Root] State locking MUST be enabled
- [All] Local state files MUST NOT be used in production
- [All] State files MUST NOT be committed to version control
- [All]
terraform apply -targetSHOULD NOT be used in normal workflows - [Root] New state storage infrastructure SHOULD follow the bootstrap workflow
- [All] Plan output MUST be reviewed for unexpected destroys or replacements before applying
- [Root] State storage buckets MUST have versioning enabled for production use
- [All] Manual state backups SHOULD be created before risky operations
- [All] State files MUST NOT be manually edited
Cross-Stack Data Sharing (Quick Reference)
- [Root] Cross-stack data SHOULD be shared via cloud-native parameter stores
- [Root]
terraform_remote_stateMAY be used with documented coupling implications - [All] Secrets MUST NOT be shared via parameter stores or remote state
Provider Management (Quick Reference)
- [All] Provider versions MUST be constrained
- [All]
.terraform.lock.hclMUST be committed to version control - [All] Pessimistic constraint operator (
~>) SHOULD be used for providers - [All] Multi-region or multi-account deployments MUST use provider aliases
Security (Quick Reference)
- [All] Secrets MUST NOT appear in
.tffiles - [All] Secrets MUST NOT have default values
- [All] Secrets MUST be provided via environment variables or secret managers
- [Root] State backends MUST enable encryption
- [All] IAM policies MUST follow least-privilege principles
- [All] Wildcard actions SHOULD NOT be used in IAM policies
- [All] Sensitive values MUST NOT be used in
for_eachorcountexpressions - [All] Sensitive outputs MUST NOT be logged in CI/CD pipelines
Testing (Quick Reference)
- [Test] Test files MUST use
.tftest.hclextension - [Test] Test files SHOULD be in a
tests/directory - [Test] Tests MUST include at least one
runblock - [Test] Each
runblock MUST include at least oneassert - [Test] Variable validation SHOULD be tested
- [Test] Unit tests SHOULD use
command = plan - [Test] Integration tests MAY use
command = apply - [Module] Modules SHOULD include corresponding Terraform tests
- [Test] Mock providers SHOULD be used for unit tests
- [Test] Negative test cases SHOULD use
expect_failures
Documentation (Quick Reference)
- [Module] Modules MUST have a
README.mdwith usage examples - [All] Inline comments SHOULD explain "why," not "what"
- [All] TODO comments SHOULD include username and context
- [All] Terraform Registry reference URLs in comments MUST use the
latestpath segment, not a pinned provider or module version - [All] Error messages in validation blocks SHOULD be actionable and reference valid options or acceptable ranges
Code Authoring Guidelines (Quick Reference)
The following guidelines apply to all code authors, including human developers and AI assistants such as GitHub Copilot:
- [All] Authors MUST NOT invent providers, modules, or placeholder values without explicit confirmation of requirements
- [All] Authors MUST ask for or verify missing required information (e.g.,
bucket,region,project_id) rather than inserting assumptions - [All] Authors MUST NOT include secrets, API keys, tokens, or hardcoded sensitive information in code
- [All] Authors SHOULD default to minimal, reproducible, and well-documented code
- [All] Authors MUST only modify backend configuration when explicitly required
- [All] Authors SHOULD NOT assume a default cloud provider; when the provider is not specified, authors SHOULD use provider-agnostic examples and document that provider selection is required
- [All] Authors SHOULD include
descriptionfor all variables and outputs, and usesensitive = trueas appropriate - [All] Authors MUST NOT modify lock files (
.terraform.lock.hcl) or commit state unless explicitly required
Terraform Version Requirements
The following table summarizes Terraform version requirements for features referenced in this document:
| Feature | Minimum Terraform Version |
|---|---|
moved blocks |
1.1.0 |
nullable variable attribute |
1.1.0 |
precondition / postcondition blocks |
1.2.0 |
replace_triggered_by lifecycle argument |
1.2.0 |
optional() type constraint modifier |
1.3.0 |
terraform_data resource |
1.4.0 |
check blocks |
1.5.0 |
import blocks |
1.5.0 |
Native test framework (terraform test) |
1.6.0 |
removed blocks |
1.7.0 |
mock_provider in tests |
1.7.0 |
ephemeral values |
1.10.0 |
Note: Examples in this document assume Terraform 1.10.0 or later unless otherwise noted. Users on older Terraform versions should verify feature availability before adopting specific patterns.
Version Upgrade Requirements
The following requirements apply to Terraform version upgrades:
- Version upgrades MUST be tested in non-production environments first
- State SHOULD be backed up before any version upgrade
.terraform.lock.hclMUST be updated and committed after version changes- Major version upgrades MUST include review of the official upgrade guide
Formatting and Style
terraform fmt Compliance
All Terraform code MUST pass terraform fmt without modifications. This is non-negotiable.
Verification command:
terraform fmt -check -recursive
Auto-format command:
terraform fmt -recursive
Pre-commit integration (for repositories that support POSIX-compatible shell hook execution):
- repo: https://github.com/antonbabenko/pre-commit-terraform
rev: v1.105.0
hooks:
- id: terraform_fmt
Indentation Rules
- Code MUST use 2 spaces for indentation
- Tabs MUST NOT be used
- Nested blocks MUST maintain consistent indentation
- Alignment of
=signs is handled automatically byterraform fmt
Compliant:
resource "aws_instance" "example" {
ami = var.ami_id
instance_type = var.instance_type
tags = {
Name = var.instance_name
Environment = var.environment
}
}
File Encoding
All Terraform files MUST use UTF-8 encoding without BOM (Byte Order Mark).
File Endings
All files MUST end with a single newline character. Trailing blank lines MUST NOT be present.
Line Length
Lines SHOULD NOT exceed 120 characters. Exceptions are permitted for:
- Long strings that cannot be reasonably split
- URLs in comments or string values
- Complex expressions where splitting reduces readability
Blank Lines
Blank lines MUST be completely empty—they MUST NOT contain any whitespace characters (spaces or tabs).
Use blank lines to:
- Separate logical sections within a file
- Separate resource blocks
- Separate groups of related arguments within a block
Comment Style
Single-line comments:
Use # for single-line comments. Comments SHOULD be placed on their own line above the code they describe.
# Enable encryption to meet compliance requirements
resource "aws_s3_bucket_server_side_encryption_configuration" "main" {
bucket = aws_s3_bucket.main.id
# ...
}
Multi-line comments:
Use /* */ sparingly for multi-line explanations when a single # comment is insufficient.
/*
* This security group allows inbound traffic from the corporate VPN.
* CIDR ranges are managed by the network team and should not be
* modified without their approval.
*/
resource "aws_security_group" "vpn_access" {
# ...
}
Naming Conventions
Resource Naming
Resources MUST use snake_case for names. Names SHOULD be descriptive and indicate purpose.
| Resource Type | Naming Pattern | Example |
|---|---|---|
| Primary/main resource | main or descriptive name |
aws_instance.main |
| Multiple of same type | Purpose-based suffix | aws_instance.web_server |
| Associated resources | Parent reference | aws_security_group.web_server |
Anti-patterns to avoid:
| Bad | Good | Reason |
|---|---|---|
aws_instance.this |
aws_instance.main |
"this" is not descriptive |
aws_instance.instance1 |
aws_instance.primary |
Numeric suffixes are meaningless |
aws_instance.MyInstance |
aws_instance.my_instance |
Must be snake_case |
aws_instance.i |
aws_instance.web_server |
Single-letter names lack meaning |
Variable Naming
Variables MUST use snake_case and MUST be descriptive.
| Category | Pattern | Example |
|---|---|---|
| Simple values | <noun> or <adjective>_<noun> |
instance_type, environment |
| Lists/Sets | Plural nouns | subnet_ids, security_group_ids |
| Maps | <noun>_map or descriptive |
tags, instance_settings |
| Booleans | enable_*, is_*, has_* |
enable_monitoring, is_public |
| Resource references | <resource>_id or <resource>_arn |
vpc_id, role_arn |
Compliant variable names:
variable "environment" {
description = "Deployment environment (dev, staging, prod)"
type = string
}
variable "enable_monitoring" {
description = "Enable CloudWatch detailed monitoring"
type = bool
default = false
}
variable "subnet_ids" {
description = "List of subnet IDs for deployment"
type = list(string)
}
Output Naming
Outputs MUST use snake_case and SHOULD follow the pattern of the attribute being exposed.
| Output Type | Pattern | Example |
|---|---|---|
| Resource ID | <resource>_id |
instance_id, vpc_id |
| Resource ARN | <resource>_arn |
role_arn, bucket_arn |
| Resource name | <resource>_name |
bucket_name, cluster_name |
| Endpoints/URLs | <resource>_endpoint |
rds_endpoint, api_endpoint |
| Collections | Plural form | instance_ids, subnet_ids |
Module Naming
Module directory names MUST use hyphen-separated lowercase words.
Compliant:
modules/
├── vpc-network/
├── ec2-instance/
├── rds-database/
└── s3-bucket/
Non-compliant:
modules/
├── VpcNetwork/ # PascalCase
├── ec2_instance/ # snake_case
└── rdsdatabase/ # No separation
Data Source Naming
When multiple data sources of the same type exist, they MUST be prefixed with their purpose.
Single data source:
data "aws_ami" "amazon_linux" {
# ...
}
Multiple data sources:
data "aws_ami" "web_server" {
# ...
}
data "aws_ami" "database_server" {
# ...
}
Local Value Naming
Local values MUST use snake_case with descriptive names.
locals {
common_tags = {
Environment = var.environment
Project = var.project_name
ManagedBy = "terraform"
}
instance_name = "${var.project_name}-${var.environment}"
}
Boolean Naming Patterns
Boolean variables and locals SHOULD use these prefixes:
| Prefix | Use Case | Example |
|---|---|---|
enable_* |
Feature flags | enable_monitoring, enable_encryption |
is_* |
State checks | is_public, is_production |
has_* |
Presence checks | has_custom_domain, has_ssl_certificate |
Globally Unique Resource Names
Some cloud resources require globally unique names across all customers. Attempting to create these resources with simple, predictable names often results in 409 Conflict, BucketAlreadyExists, or similar errors.
Resources Requiring Globally Unique Names
| Provider | Resources |
|---|---|
| AWS | S3 buckets, some IAM resources (roles with path-based ARNs) |
| Azure | Storage Accounts, Key Vaults, App Services, Cosmos DB accounts |
| GCP | GCS buckets, project IDs, Cloud SQL instances |
Recommended Patterns
Pattern 1: Random suffix using random_id:
Use the random_id resource to generate a unique suffix that remains stable across applies:
AWS Example:
resource "random_id" "bucket_suffix" {
byte_length = 4
}
resource "aws_s3_bucket" "main" {
bucket = "${var.project_name}-${var.environment}-${random_id.bucket_suffix.hex}"
}
Note: If you are not using AWS, replace
aws_s3_bucketand thebucketargument with the equivalent resource type and naming attribute for your provider.
Provider Configuration File
Root modules MUST have a providers.tf file with provider configuration:
AWS Example:
# providers.tf
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Environment = var.environment
Project = var.project_name
ManagedBy = "terraform"
}
}
}
Note: Azure does not support provider-level default tags; define common tags in a
localsblock. GCP supportsdefault_labelsat the provider level (Google provider 4.x+), but label keys must be lowercase.
Backend Configuration
Root modules MUST configure a remote backend, either in backend.tf or within the terraform block:
AWS Example:
# backend.tf - Example configuration
terraform {
backend "s3" {
bucket = "acme-corp-terraform-state" # Use your state bucket name
key = "environments/prod/terraform.tfstate"
region = "us-east-1" # Use your preferred region
encrypt = true
dynamodb_table = "terraform-locks"
}
}
Note: Azure uses the
azurermbackend with a Storage Account, and GCP uses thegcsbackend with a Cloud Storage bucket.Important: Unlike resource blocks, backend blocks do not support variable interpolation. Example values in backend configuration (such as bucket names and regions) must be replaced with your organization's actual values before running
terraform init. Alternatively, use partial backend configuration to provide values at runtime.
Partial Backend Configuration
As an alternative to placeholder values, Terraform supports partial backend configuration. This pattern separates static configuration (committed to version control) from dynamic values (provided at runtime):
AWS Backend file (committed):
# backend.tf - partial configuration
terraform {
backend "s3" {
key = "environments/prod/terraform.tfstate"
encrypt = true
# bucket, region, and dynamodb_table provided via -backend-config
}
}
AWS Backend config file (environment-specific):
# config/prod.s3.tfbackend
bucket = "acme-corp-terraform-state"
region = "us-east-1"
dynamodb_table = "terraform-locks"
Usage:
terraform init -backend-config=config/prod.s3.tfbackend # AWS
terraform init -backend-config=config/prod.azurerm.tfbackend # Azure
terraform init -backend-config=config/prod.gcs.tfbackend # GCP
This pattern is useful when:
- Backend values vary by environment but the state key structure is consistent
- Teams prefer runtime configuration over placeholder replacement
- CI/CD pipelines inject backend configuration dynamically
Both the inline example values and partial configuration pattern are valid approaches. Choose the pattern that best fits your team's workflow.
Variable Files (.tfvars)
Terraform variable files (.tfvars) provide environment-specific or deployment-specific values. This section defines conventions for organizing and managing these files.
Variable File Naming Conventions
| Pattern | Use Case | Example |
|---|---|---|
terraform.tfvars |
Default values loaded automatically | terraform.tfvars |
<environment>.tfvars |
Environment-specific values | prod.tfvars, dev.tfvars |
<environment>.auto.tfvars |
Auto-loaded environment values | prod.auto.tfvars |
Content Guidelines
What belongs in .tfvars files:
- Environment-specific values (e.g., instance sizes, replica counts)
- Non-sensitive configuration overrides
- Deployment-specific settings
What does NOT belong in .tfvars files:
- Secrets, API keys, or passwords — see Security Best Practices
- Hardcoded credentials or tokens
- Any value that should not be committed to version control
Relationship to variable defaults:
Values in .tfvars files override default values in variable declarations. The loading order is:
defaultvalue invariables.tf- Environment variables (
TF_VAR_name) terraform.tfvarsandterraform.tfvars.json(if present)*.auto.tfvarsand*.auto.tfvars.jsonfiles (in alphabetical order)-var-filecommand-line arguments (in order specified)-varcommand-line arguments (later flags override earlier ones)
Version Control Guidelines
- Non-sensitive
.tfvarsfiles MAY be committed to version control - Sensitive
.tfvarsfiles MUST NOT be committed; use.tfvars.exampletemplates instead - Template files SHOULD use the
.tfvars.exampleextension to indicate they require customization
Example .gitignore patterns for sensitive tfvars:
# Sensitive variable files (use *.tfvars.example as templates)
*.sensitive.tfvars
*-secrets.tfvars
Example File Structure
environments/
├── prod/
│ ├── main.tf
│ ├── variables.tf
│ ├── terraform.tfvars # Committed: non-sensitive defaults
│ ├── secrets.tfvars.example # Committed: template for secrets
│ └── secrets.tfvars # NOT committed: actual secrets
└── dev/
├── main.tf
├── variables.tf
└── terraform.tfvars
Terraform Cloud Variable Precedence
When using Terraform Cloud or Terraform Enterprise, variables can be set at multiple levels. The precedence order (highest to lowest) is:
-varand-var-fileflags in CLI-driven runs*.auto.tfvarsfiles (in alphabetical order)terraform.tfvars(if present)- Workspace-specific variables (set in Terraform Cloud UI/API)
- Variable sets (shared across workspaces)
- Environment variables (
TF_VAR_*) defaultvalues in variable declarations
Note: In Terraform Cloud, workspace variables and variable sets take precedence over environment variables (TF_VAR_*). This differs from local Terraform execution where environment variables have higher precedence.
Document which variable management approach your organization uses in the Scope Exceptions section to ensure consistency across team members.
Module Directory Structure
Standard module directory structure:
modules/
└── <module-name>/
├── main.tf # Primary resources
├── variables.tf # Input variables (REQUIRED)
├── outputs.tf # Output values (REQUIRED)
├── versions.tf # Version constraints (REQUIRED)
├── README.md # Module documentation (REQUIRED)
├── locals.tf # Local values (when needed)
├── data.tf # Data sources (when needed)
├── examples/ # Usage examples (RECOMMENDED)
│ └── basic/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
└── tests/ # Test files (RECOMMENDED)
└── basic.tftest.hcl
Module Examples
Modules SHOULD include an examples/ directory with working examples:
- Each example MUST be a complete, runnable configuration
- Examples SHOULD demonstrate common use cases
- Examples SHOULD include a
README.mdexplaining the example
Module Tests
Modules SHOULD include a tests/ directory with Terraform test files:
- Tests MUST use the
.tftest.hclextension - Tests SHOULD cover both valid and invalid inputs
- Tests SHOULD validate critical outputs
Template Files (.tftpl)
Template files are used with the templatefile() function to generate dynamic content such as configuration files, scripts, or policy documents. Template files SHOULD follow these conventions:
- Template files SHOULD use the
.tftplextension for clear identification - Template files SHOULD be placed in a
templates/subdirectory within the module or root configuration - Template file variables SHOULD be documented at the top of the template file using comments
Directory structure:
modules/
└── <module-name>/
├── main.tf
├── variables.tf
├── outputs.tf
└── templates/
├── user_data.sh.tftpl
└── policy.json.tftpl
Template file example with documentation:
#!/bin/bash
# Template: user_data.sh.tftpl
# Variables:
# - environment: Deployment environment (string)
# - app_name: Application name (string)
# - enable_monitoring: Whether to enable monitoring (bool)
echo "Deploying ${app_name} to ${environment}"
%{ if enable_monitoring ~}
echo "Monitoring enabled"
%{ endif ~}
Using templatefile() function:
resource "aws_instance" "main" {
ami = var.ami_id
instance_type = var.instance_type
user_data = templatefile("${path.module}/templates/user_data.sh.tftpl", {
environment = var.environment
app_name = var.app_name
enable_monitoring = var.enable_monitoring
})
}
Variable and Output Design
Variable Documentation Requirements
Every variable MUST include a description. The description SHOULD explain:
- What the variable is for
- Valid values or constraints
- Any special considerations
variable "environment" {
description = "Deployment environment. Valid values: dev, staging, prod."
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be dev, staging, or prod."
}
}
Variable Type Constraints
Variables MUST include explicit type constraints:
# String variable
variable "instance_type" {
description = "EC2 instance type for the application server."
type = string
default = "t3.micro"
}
# List variable
variable "subnet_ids" {
description = "List of subnet IDs for deployment."
type = list(string)
}
# Map variable
variable "tags" {
description = "Additional tags to apply to resources."
type = map(string)
default = {}
}
# Object variable
variable "instance_config" {
description = "Configuration for the EC2 instance."
type = object({
instance_type = string
ami_id = string
volume_size = optional(number, 20)
})
}
Variable Defaults
Optional variables MUST have a default value. Required variables MUST NOT have a default.
# Required variable (no default)
variable "vpc_id" {
description = "VPC ID where resources will be created."
type = string
}
# Optional variable (has default)
variable "enable_monitoring" {
description = "Enable CloudWatch detailed monitoring."
type = bool
default = false
}
Variable Validation
Variables with constrained values SHOULD use validation blocks:
variable "environment" {
description = "Deployment environment. Valid values: dev, staging, prod."
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be one of: dev, staging, prod."
}
}
variable "instance_count" {
description = "Number of instances to create. Must be between 1 and 10."
type = number
validation {
condition = var.instance_count >= 1 && var.instance_count <= 10
error_message = "Instance count must be between 1 and 10."
}
}
variable "cidr_block" {
description = "CIDR block for the VPC."
type = string
validation {
condition = can(cidrhost(var.cidr_block, 0))
error_message = "Must be a valid CIDR block."
}
}
Sensitive Variable Marking
Variables containing sensitive data MUST be marked:
variable "database_password" {
description = "Password for the RDS database. Must be provided via environment variable or tfvars."
type = string
sensitive = true
}
variable "api_key" {
description = "API key for external service"
type = string
sensitive = true
}
Output Documentation Requirements
Every output MUST include a description:
output "instance_id" {
description = "The ID of the created EC2 instance."
value = aws_instance.main.id
}
output "instance_public_ip" {
description = "The public IP address of the EC2 instance."
value = aws_instance.main.public_ip
}
Sensitive Output Marking
Outputs containing sensitive data MUST be marked:
output "database_connection_string" {
description = "Database connection string (contains credentials)."
value = local.connection_string
sensitive = true
}
output "instance_private_ip" {
description = "The private IP address of the EC2 instance."
value = aws_instance.main.private_ip
sensitive = true
}
Nullable Variables
The nullable attribute (Terraform 1.1+) controls whether a variable can accept null as a valid value. By default, all variables have nullable = true, meaning they can accept null values.
Purpose:
- Explicitly allow or disallow
nullas a valid value - Distinguish between "not provided" and "explicitly set to null"
- Improve input validation for required values
When to use explicit nullable:
- Use
nullable = truewhennullis a meaningful value distinct from the default - Use
nullable = falsewhennullvalues MUST be rejected
Example:
variable "optional_cidr" {
description = "Optional secondary CIDR block. Set to null to skip configuration."
type = string
default = null
nullable = true # Explicitly allow null values
}
variable "required_name" {
description = "Required resource name. Cannot be null."
type = string
nullable = false # Null values will be rejected
}
Usage pattern:
resource "aws_vpc_ipv4_cidr_block_association" "secondary" {
count = var.optional_cidr != null ? 1 : 0
vpc_id = aws_vpc.main.id
cidr_block = var.optional_cidr
}
Null Value Patterns
Terraform provides several functions and patterns for handling null values effectively. This section documents common patterns for working with potentially null values.
Using coalesce() for fallback values:
locals {
# Use provided value or fall back to default
instance_type = coalesce(var.instance_type_override, "t3.micro")
# Chain multiple fallbacks
region = coalesce(var.region, data.aws_region.current.name, "us-east-1")
}
Handling null in conditional expressions:
resource "aws_instance" "main" {
ami = var.ami_id
instance_type = var.instance_type
# Only set user_data if provided, otherwise use default script
user_data = var.custom_user_data != null ? var.custom_user_data : file("${path.module}/default_user_data.sh")
}
Null handling in for expressions:
locals {
# Filter out null values from a list
valid_subnets = [for s in var.subnet_ids : s if s != null]
# Filter out entries with null values from a map
valid_tags = { for k, v in var.tags : k => v if v != null }
}
Using optional() with defaults (Terraform 1.3+):
variable "instance_config" {
type = object({
instance_type = string
volume_size = optional(number, 20) # Defaults to 20 when attribute is not set
monitoring = optional(bool, false) # Defaults to false when attribute is not set
})
}
Defensive Attribute Access with try()
The try() function attempts to evaluate expressions and returns a fallback value if any expression fails. Use it for defensive access to attributes that may not exist.
Syntax: try(expression, fallback)
Use cases:
locals {
# Safely access nested attributes that may not exist
instance_ip = try(aws_instance.main.private_ip, "unknown")
# Handle optional nested blocks
root_volume_size = try(aws_instance.main.root_block_device[0].volume_size, 8)
# Chain multiple attempts
endpoint = try(
aws_db_instance.main.endpoint,
aws_rds_cluster.main.endpoint,
"no-database-configured"
)
}
Difference between try() and can():
| Function | Returns | Use Case |
|---|---|---|
try(expr, fallback) |
The value of expr if successful, otherwise fallback |
Getting a value with a default |
can(expr) |
true if expr evaluates without error, false otherwise |
Validation conditions |
# Use can() in validation blocks
validation {
condition = can(regex("^[a-z][a-z0-9-]*$", var.name))
error_message = "Name must start with a letter and contain only lowercase alphanumeric characters and hyphens."
}
# Use try() when you need the actual value
locals {
parsed_json = try(jsondecode(var.config_json), {})
}
Continuous Validation with check Blocks
The check block (Terraform 1.5+) provides a mechanism for continuous validation assertions that run on every plan and apply operation. Unlike precondition and postcondition blocks, check blocks produce warning diagnostics that do not halt execution when assertions fail.
When to Use check Blocks
check blocks MAY be used for deterministic validations that rely only on Terraform configuration, state, and resource attributes, such as:
- Policy conformance: Ensure resources comply with tagging, encryption, and sizing standards
- Configuration invariants: Assert relationships between resources (for example, capacity, counts, or naming patterns)
- Drift and safety nets: Highlight situations where the actual infrastructure shape no longer matches declared expectations
In this repository, check block assert conditions MUST be deterministic and MUST NOT introduce network calls or depend on live external service reachability. Use dedicated observability/monitoring tooling for runtime health checks and external dependency validation.
check Block Syntax
check "alb_has_listeners" {
assert {
condition = length(aws_lb_listener.app) > 0
error_message = "Application load balancer must have at least one listener configured."
}
}
Comparison with precondition/postcondition
| Feature | check blocks |
precondition/postcondition |
|---|---|---|
| Causes failure | No (warning only) | Yes (error) |
| Runs during | Every plan/apply | Resource creation/update |
| Scope | Configuration-wide | Resource-specific |
| Use case | Ongoing invariant checks | Input/output validation |
Best Practices
- Bounded usage: Each module SHOULD define at most 3
checkblocks and SHOULD reserve them for critical invariants (for example, cross-resource consistency or security posture), not routine validation that can be handled withprecondition/postcondition. - Deterministic and cheap assertions:
conditionexpressions MUST be deterministic (no dependence on current time, randomness, or external services) and MUST NOT introduce additional network calls (for example, avoidhttpor other remote data sources inside acheckblock). They SHOULD only reference values already available during planning (resource attributes, variables, locals, and data sources the plan already needs). - Constrained complexity: Assertions SHOULD be expressed as a small number of boolean expressions combined with
&&/||, and SHOULD NOT iterate over large collections or use deeply nested conditionals that materially increase plan/apply latency. - Document purpose:
error_messagevalues MUST clearly state which invariant failed and what the operator SHOULD do next (for example, "rotate API token X" or "scale service Y").
Resource Configuration
Meta-Argument Ordering
Within resource blocks, arguments MUST follow this order:
- Meta-arguments first:
count,for_each,provider,depends_on,lifecycle - Required arguments: Arguments without defaults
- Optional arguments: Arguments with defaults
- Nested blocks last: Dynamic blocks, inline blocks
Compliant example:
resource "aws_instance" "web_server" {
# Meta-arguments first
count = var.instance_count
provider = aws.primary
# Required arguments
ami = data.aws_ami.amazon_linux.id
instance_type = var.instance_type
subnet_id = var.subnet_id
# Optional arguments
associate_public_ip_address = var.is_public
monitoring = var.enable_monitoring
# Nested blocks last
root_block_device {
volume_size = var.root_volume_size
encrypted = true
}
tags = local.common_tags
# Lifecycle block at the end
lifecycle {
create_before_destroy = true
}
}
Argument Ordering
Within the arguments section:
- Required arguments appear before optional arguments
- Related arguments are grouped together
tagstypically appears last before nested blocks
Nested Block Placement
Nested blocks MUST appear after all simple arguments:
resource "aws_security_group" "web" {
# Simple arguments first
name = "${var.project_name}-web-sg"
description = "Security group for web servers"
vpc_id = var.vpc_id
# Nested blocks last
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = local.common_tags
}
Required Tags
All taggable resources MUST include these tags:
| Tag | Description | Example |
|---|---|---|
Name |
Human-readable resource name | prod-web-server-1 |
Environment |
Deployment environment | prod, staging, dev |
Project |
Project or application name | my-application |
ManagedBy |
Management method | terraform |
Owner |
Team or individual owner | platform-team |
Default Tags Configuration
Root modules SHOULD use provider-level default tags to ensure consistent tagging:
AWS Example:
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Environment = var.environment
Project = var.project_name
ManagedBy = "terraform"
Owner = var.owner_team
}
}
}
Note: Azure does not support provider-level default tags. GCP supports
default_labelsat the provider level (Google provider 4.x+), but label keys must be lowercase. For Azure, use alocalsblock to define common tags. For GCP, you may usedefault_labelsor alocalsblock if you need consistent lowercase key enforcement. See the Local Tags Pattern section below.
Local Tags Pattern
Use locals for computed or merged tags. This pattern is REQUIRED for Azure (no provider-level support) and RECOMMENDED for GCP when consistent lowercase label key enforcement is needed:
AWS/Azure Example (Tags):
locals {
common_tags = {
Name = "${var.project_name}-${var.environment}"
Environment = var.environment
Project = var.project_name
ManagedBy = "terraform"
}
# Merge common tags with resource-specific tags
instance_tags = merge(local.common_tags, {
Role = "web-server"
})
}
Note: GCP label keys must be lowercase and can only contain lowercase letters, numeric characters, underscores, and dashes. AWS and Azure tags support mixed-case keys.
Lifecycle Validation
Terraform 1.2+ introduced precondition and postcondition blocks within the lifecycle block, enabling resource-level validation of assumptions and outcomes.
Resource Preconditions
precondition blocks SHOULD validate assumptions before resource creation. Use them to ensure that related resources or variables meet requirements before Terraform attempts to create or modify a resource.
resource "aws_instance" "main" {
ami = var.ami_id
instance_type = var.instance_type
lifecycle {
precondition {
condition = var.environment != "prod" || var.enable_monitoring
error_message = "Production instances must have monitoring enabled."
}
}
}
Use cases for preconditions:
- Validating cross-resource dependencies
- Enforcing business rules that span multiple variables
- Ensuring prerequisites are met before expensive operations
Resource Postconditions
postcondition blocks SHOULD validate resource state after creation. Use them to ensure that computed values meet expectations after Terraform creates or modifies a resource.
resource "aws_instance" "main" {
ami = var.ami_id
instance_type = var.instance_type
lifecycle {
postcondition {
condition = self.public_ip != null
error_message = "Instance must have a public IP assigned."
}
}
}
Use cases for postconditions:
- Validating computed attributes (IPs, ARNs, generated names)
- Ensuring provider-side defaults meet expectations
- Catching unexpected resource configurations
Lifecycle Block Options
The lifecycle block supports several meta-argument options beyond precondition and postcondition for controlling resource behavior. The following table summarizes these lifecycle meta-arguments:
| Option | Purpose | Use Case |
|---|---|---|
create_before_destroy |
Create replacement before destroying original | Zero-downtime replacements |
prevent_destroy |
Prevent accidental resource deletion | Protect critical production resources |
ignore_changes |
Ignore changes to specific attributes | Attributes managed outside Terraform |
replace_triggered_by |
Force replacement when dependencies change | Trigger replacement on related resource changes (Terraform 1.2+) |
When to Use Each Option
create_before_destroy: Use when replacing a resource must not cause downtime. The new resource is created first, then the old one is destroyed.
prevent_destroy: Use for critical resources that MUST NOT be accidentally deleted, such as production databases, state storage buckets, or encryption keys.
ignore_changes: Use when an attribute is intentionally managed outside Terraform (e.g., auto-scaling group desired capacity, tags managed by external tools).
replace_triggered_by: Use when a resource SHOULD be replaced whenever a related resource changes, even if no direct attributes are affected.
Example: Protecting a Critical Resource
# Protect production database from accidental deletion
resource "aws_db_instance" "production" {
identifier = "prod-database"
engine = "postgres"
instance_class = var.db_instance_class
# ... other configuration
lifecycle {
prevent_destroy = true
}
}
# Protect Terraform state bucket
resource "aws_s3_bucket" "terraform_state" {
bucket = "acme-corp-terraform-state"
lifecycle {
prevent_destroy = true
}
}
Example: Ignoring External Changes
# Ignore changes to tags managed by AWS Config or other external tools
resource "aws_instance" "main" {
ami = var.ami_id
instance_type = var.instance_type
lifecycle {
ignore_changes = [
tags["LastScannedBy"],
tags["ComplianceStatus"],
]
}
}
Resource Timeouts
Some Terraform resources support custom timeout configurations for create, update, and delete operations via a timeouts block. Timeouts are provider-specific—not all resources support them, and available timeout options vary by resource type.
Timeout Block Structure
resource "aws_db_instance" "main" {
identifier = "production-database"
engine = "postgres"
instance_class = var.db_instance_class
# ... other configuration
timeouts {
create = "60m"
update = "90m"
delete = "30m"
}
}
Common Use Cases
Custom timeouts are commonly needed for:
- RDS/database instances: Database creation and modification can take 30-60+ minutes
- Large EKS/AKS/GKE clusters: Kubernetes cluster operations may exceed default timeouts
- Complex networking resources: VPN gateways, Transit Gateway attachments, and peering connections
- Large-scale storage operations: Creating or resizing large storage volumes
Note: Default timeouts are usually sufficient for most operations. Custom timeouts SHOULD only be set when operations consistently exceed default values or when specific SLAs require longer wait times.
Explicit Dependencies
Terraform automatically infers dependencies from resource references. The depends_on meta-argument SHOULD be avoided unless dependencies are not inferable from the configuration.
When depends_on is Appropriate
depends_on SHOULD only be used for:
- Hidden dependencies: When a resource depends on another resource's side effects (e.g., IAM policy propagation delays)
- Module dependencies: When a module depends on another module's resources without direct references
- Timing issues: When the order of operations matters but isn't reflected in resource attributes
Anti-pattern: Redundant depends_on
# BAD: Unnecessary depends_on - dependency is already inferred from the reference
resource "aws_instance" "main" {
subnet_id = aws_subnet.main.id
depends_on = [aws_subnet.main] # Redundant
}
# GOOD: Dependency is implicit from the reference
resource "aws_instance" "main" {
subnet_id = aws_subnet.main.id
}
Legitimate Use Case
# GOOD: Hidden dependency on IAM role policy attachment
resource "aws_instance" "main" {
ami = data.aws_ami.amazon_linux.id
instance_type = "t3.micro"
iam_instance_profile = aws_iam_instance_profile.main.name
# The instance profile references the role, but doesn't reference the policy attachment.
# Without depends_on, the instance may launch before the policy is attached.
depends_on = [aws_iam_role_policy_attachment.main]
}
Module-Level depends_on
The depends_on meta-argument can also be used at the module level (Terraform 0.13+). However, module-level depends_on has different implications than resource-level depends_on and SHOULD be used sparingly.
Key differences from resource-level depends_on:
- When a module block uses
depends_onwith a module address (for example,depends_on = [module.networking]), it creates a dependency on all resources in that referenced module - When a module block uses
depends_onwith a resource address, it creates a dependency only on that specific resource (not all resources in its module) - This can cause unnecessary serialization and slower apply times when used with module addresses
- It is a blunt instrument that should only be used when finer-grained dependencies cannot be expressed
When module-level depends_on is appropriate:
- When a module depends on side effects from another module (e.g., IAM propagation, DNS resolution delays)
- When there is no data to pass between modules but ordering is required
- When debugging timing issues during development (should be removed after identifying root cause)
Example: Legitimate use case:
# Module-level depends_on - use sparingly
module "application" {
source = "./modules/application"
vpc_id = module.networking.vpc_id
# Only use when implicit dependencies are insufficient
# (e.g., module.networking creates IAM roles needed by the application)
depends_on = [module.networking]
}
Anti-pattern: Redundant module depends_on:
# AVOID: Unnecessary module depends_on when data is already passed
module "application" {
source = "./modules/application"
vpc_id = module.networking.vpc_id # This creates implicit dependency
subnet_ids = module.networking.subnet_ids # This too
depends_on = [module.networking] # REDUNDANT - remove this
}
Best practice: Prefer explicit data passing between modules over depends_on. When you pass outputs from one module as inputs to another, Terraform automatically understands the dependency relationship.
for_each vs count
When creating multiple instances of a resource, for_each SHOULD be preferred over count for collections where resources have unique identifiers.
Comparison Table
Use for_each when... |
Use count when... |
|---|---|
| Resources have unique identifiers | Simple on/off toggle (count = var.enabled ? 1 : 0) |
| Order doesn't matter | Resources are truly identical and ordered |
| Items may be added/removed from middle of collection | Only adding/removing from the end |
| Each resource is addressable by key | Index-based addressing is acceptable |
Why for_each is Preferred
# BAD: Using count with a list - removing any item shifts all subsequent indices
resource "aws_instance" "servers" {
count = length(var.server_names)
ami = data.aws_ami.amazon_linux.id
instance_type = "t3.micro"
tags = {
Name = var.server_names[count.index]
}
}
# GOOD: Using for_each with a set - removing "server-b" only affects that resource
resource "aws_instance" "servers" {
for_each = toset(var.server_names)
ami = data.aws_ami.amazon_linux.id
instance_type = "t3.micro"
tags = {
Name = each.value
}
}
When count is Appropriate
# GOOD: count for conditional resource creation
resource "aws_cloudwatch_log_group" "main" {
count = var.enable_logging ? 1 : 0
name = "/app/${var.environment}"
}
Conditional Resource Creation
The count meta-argument is appropriate for conditional resource creation using a boolean toggle:
variable "enable_logging" {
description = "Whether to create the CloudWatch log group"
type = bool
default = false
}
resource "aws_cloudwatch_log_group" "main" {
count = var.enable_logging ? 1 : 0
name = "/app/${var.environment}"
}
Referencing conditional resources:
When referencing a conditionally created resource, use index [0] and handle the case where it doesn't exist:
# Safe reference to conditional resource
output "log_group_arn" {
description = "ARN of the log group, if created"
value = var.enable_logging ? aws_cloudwatch_log_group.main[0].arn : null
}
# Alternative using try() for readability
# try() returns the first argument when the resource exists, or null when it does not
output "log_group_name" {
description = "Name of the log group, if created"
value = try(aws_cloudwatch_log_group.main[0].name, null)
}
Dynamic Blocks
Dynamic blocks allow generating multiple nested blocks from a collection. They SHOULD be used sparingly because they reduce readability and complicate debugging.
When to Use Dynamic Blocks
Use dynamic blocks when:
- The number of nested blocks is variable and determined by configuration
- The block structure is repetitive and follows a consistent pattern
- Manual repetition would create maintenance burden
# GOOD: Variable number of ingress rules from configuration
resource "aws_security_group" "main" {
name = "${var.project_name}-sg"
description = "Security group for ${var.project_name}"
vpc_id = var.vpc_id
dynamic "ingress" {
for_each = var.ingress_rules
content {
from_port = ingress.value.from_port
to_port = ingress.value.to_port
protocol = ingress.value.protocol
cidr_blocks = ingress.value.cidr_blocks
}
}
}
When to Avoid Dynamic Blocks
Avoid dynamic blocks when the number of blocks is fixed and small. Writing explicit blocks improves readability:
# GOOD: Fixed, small number of rules - explicit is clearer
resource "aws_security_group" "web" {
name = "${var.project_name}-web-sg"
description = "Web security group"
vpc_id = var.vpc_id
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
# AVOID: Using dynamic for only 2-3 fixed rules adds unnecessary complexity
# dynamic "ingress" { ... }
The terraform_data Resource
The terraform_data resource (Terraform 1.4+) is a built-in managed resource that provides trigger-based replacement and data passing without requiring any provider. It is the preferred replacement for null_resource in modern Terraform configurations.
When to Use terraform_data
- Trigger-based replacement: Force resource replacement when specific values change
- Data passing: Store and pass values between resources or modules
- Provisioner execution: Run local-exec or remote-exec provisioners (same as null_resource)
Basic Usage Pattern
resource "terraform_data" "replacement" {
input = var.revision
# Force replacement when any of these values change
triggers_replace = [
aws_instance.main.id,
var.force_replacement,
]
}
# Reference the trigger in other resources
resource "aws_instance" "dependent" {
# ...
lifecycle {
replace_triggered_by = [terraform_data.replacement]
}
}
Using input and output Attributes
The input attribute accepts any value and makes it available as output:
resource "terraform_data" "config" {
input = {
environment = var.environment
version = var.app_version
timestamp = timestamp()
}
}
# Access the stored values
output "deployment_config" {
description = "Configuration used for this deployment."
value = terraform_data.config.output
}
Migration from null_resource
When migrating from null_resource to terraform_data:
# Before (null_resource)
resource "null_resource" "trigger" {
triggers = {
instance_id = aws_instance.main.id
}
}
# After (terraform_data) - preferred
resource "terraform_data" "trigger" {
triggers_replace = [aws_instance.main.id]
}
Note:
terraform_datarequires Terraform 1.4.0 or later. For configurations that must support older versions,null_resourceremains available via thehashicorp/nullprovider.
Module Design
Single Responsibility
Modules MUST have a single, well-defined responsibility:
- Each module SHOULD manage one logical component
- Modules SHOULD NOT try to do too much
- Complex infrastructure SHOULD be composed of multiple modules
Good: A VPC module that creates VPC, subnets, route tables, and internet gateway.
Bad: A "full-stack" module that creates VPC, EC2, RDS, and S3 all together.
Module Version Constraints
Modules MUST specify required Terraform and provider versions:
# versions.tf in module directory
terraform {
required_version = ">= 1.7.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 6.0.0"
}
}
}
Module Interface Design
Inputs:
- Variable names MUST be consistent across modules (e.g., always
environment, not sometimesenv) - Required variables SHOULD be minimized to essential values
- Complex inputs SHOULD use object types with documented structure
Outputs:
- Expose only values needed by calling modules
- Use consistent naming patterns across modules
- Document output types and formats
Minimal Required Inputs
Required variables SHOULD be minimized. Provide sensible defaults where possible:
# Good: Only truly required inputs are mandatory
variable "vpc_id" {
description = "VPC ID where resources will be created."
type = string
# No default - this is genuinely required
}
variable "instance_type" {
description = "EC2 instance type."
type = string
default = "t3.micro" # Sensible default
}
Complex Input Types
For complex inputs, use object types with clear documentation:
variable "instance_config" {
description = <<-EOT
Configuration for the EC2 instance.
Attributes:
- instance_type: EC2 instance type (e.g., "t3.micro")
- ami_id: AMI ID to use for the instance
- volume_
*Truncated - read the full file at https://github.com/franklesniak/test-ignore-me/blob/479cc8c9ca5f7e4ea7f6bf9f9da21bc31f6967ef/.github/instructions/terraform.instructions.md.*