Terraform HCL
HCL (HashiCorp Configuration Language) is Terraform’s declarative configuration language. Rather than describing how to build infrastructure step by step, you describe what the infrastructure should look like - Terraform figures out the correct order and sequence automatically.
Block Syntax
Section titled “Block Syntax”In HCL, blocks are the primary language construct - the “nouns” of the language. Everything in Terraform is expressed as a block. All blocks share a uniform structure:
<block_type> "<label_1>" "<label_2>" { argument_name = value
subblock { nested_argument = value }}Block Types
Section titled “Block Types”The very first word of a block is its block type, which determines how its contents are interpreted. Terraform has 12 built-in block types:
| Block Type | Purpose |
|---|---|
terraform | Configures Terraform and the workspace |
provider | Specifies settings for vendor connections |
resource | Creates and manages infrastructure components |
data | Performs read-only lookups of existing infrastructure |
variable | Declares external input parameters |
locals | Declares internally scoped computed values |
module | Calls and configures a reusable module |
output | Exports values from a module |
import | Brings existing infrastructure under Terraform management |
moved | Tells Terraform a resource has been renamed or relocated |
removed | Removes a resource from state without destroying it |
check | Validates the state of deployed infrastructure |
Labels
Section titled “Labels”Following the block type, most blocks include one or more quoted labels that give the block a unique identity for cross-referencing:
| Strategy | Which blocks | Example |
|---|---|---|
| No labels | terraform, locals | Only one instance per module; no identity needed |
| Single label | variable, provider, module, output | variable "instance_type" |
| Subtype + identifier | resource, data | resource "aws_instance" "web_server" |
For resource and data blocks, the two labels together form a unique reference string:
resource.aws_instance.web_serverdata.aws_ami.ubuntuTerraform uses these reference strings to track dependencies across the entire configuration.
Arguments and Subblocks
Section titled “Arguments and Subblocks”Inside a block body, two things can appear:
- Arguments - assign a value to a named field using
=. Each argument name can only appear once per block. Values can be strings, booleans, numbers, objects, lists, ornull. - Subblocks - nested blocks without
=, without labels, and can be repeated. A common use case is stacking multiplefiltersubblocks inside adatablock.
data "aws_subnets" "cluster" { filter { name = "tag:network_tier" values = ["compute_cluster"] } filter { name = "availabilityZone" values = ["us-east-1a", "us-east-1b"] }}Attributes
Section titled “Attributes”Blocks also export attributes - read-only values that other blocks can reference. Attributes include:
- All arguments the block was given (re-exported as attributes)
- Computed values produced by the provider after creation (e.g., an auto-assigned ARN, an IP address, an instance ID)
resource "aws_instance" "web" { ami = data.aws_ami.ubuntu.id # referencing an attribute of the data block instance_type = "t3.micro"}Block Ordering and the Dependency Graph
Section titled “Block Ordering and the Dependency Graph”In Terraform, the order blocks are written in is irrelevant. You can define blocks in any sequence, spread them across multiple files in the same directory - Terraform treats them all as a single flat configuration.
Instead of executing top-to-bottom, Terraform builds a directed acyclic graph (DAG) by tracing which block’s attributes are consumed as arguments by other blocks. This graph determines the correct creation order automatically.
# Terraform knows to fetch the VPC first, then the subnet,# purely because the subnet filter references vpc_data.iddata "aws_vpc" "default" { default = true}
data "aws_subnets" "cluster" { filter { name = "vpcId" values = [data.aws_vpc.default.id] # implicit dependency }}This automatic dependency resolution is one of Terraform’s most powerful capabilities.
Code Style Guidelines
Section titled “Code Style Guidelines”Terraform doesn’t enforce formatting to run, but HashiCorp publishes style guidelines that make codebases significantly easier for teams to read. The recommended order inside a block is:
- Meta-arguments at the top (
provider,depends_on,count,for_each) - Block-specific arguments, grouped logically, equals signs vertically aligned
- Block-specific subblocks
lifecyclesubblock at the very bottom
Use terraform fmt to automatically realign equals signs and apply whitespace conventions:
terraform fmt # format current directoryterraform fmt -recursive # format all subdirectoriesProviders
Section titled “Providers”Providers are the plugins that allow Terraform to communicate with external systems - cloud platforms, DNS registrars, monitoring services, and more. They act like vendor SDKs, telling Terraform how to interpret and manage specific infrastructure components.
Declaring Required Providers
Section titled “Declaring Required Providers”Always declare provider dependencies explicitly inside the terraform settings block. This pins the acceptable version range and protects the workspace from breaking changes in future provider releases.
terraform { required_version = ">= 1.6"
required_providers { google = { source = "hashicorp/google" version = "~> 5.0" } cloudflare = { source = "cloudflare/cloudflare" version = ">= 4.0" } }}Configuring a Provider
Section titled “Configuring a Provider”required_providers installs the dependency; the provider block configures it. Provider blocks live in the root module and handle two concerns:
- Authentication - API tokens, service account keys, credential files
- Scoping - which region, project, or account to operate in
provider "google" { project = "my-gcp-project" region = "us-central1"}Many providers can read authentication from environment variables or local credential files (e.g., GOOGLE_APPLICATION_CREDENTIALS, AWS_ACCESS_KEY_ID), making the provider block optional for auth in those cases.
Provider Aliases
Section titled “Provider Aliases”By default one configuration exists per provider. When you need to manage resources in multiple regions or accounts within the same provider, use aliases:
provider "aws" { region = "us-east-1"}
provider "aws" { alias = "west" region = "us-west-2"}
resource "aws_instance" "backup" { provider = aws.west # explicit alias selection ami = "ami-0abc123" instance_type = "t3.micro"}terraform init and the Lock File
Section titled “terraform init and the Lock File”Running terraform init downloads the declared providers from the Terraform Registry and generates a dependency lock file:
terraform initThe lock file (.terraform.lock.hcl) records the exact installed versions, ensuring future init calls reproduce the same selections:
# .terraform.lock.hcl (auto-generated, commit to source control)provider "registry.terraform.io/hashicorp/google" { version = "5.12.0" constraints = "~> 5.0" hashes = [...]}Resources and Data Sources
Section titled “Resources and Data Sources”Resources
Section titled “Resources”The resource block is the heart of Terraform - it declares a piece of infrastructure that Terraform should create, update, and manage.
resource "google_compute_instance" "web" { name = "web-server" machine_type = "e2-medium" zone = "us-central1-a"
boot_disk { initialize_params { image = data.google_compute_image.ubuntu.self_link } }
network_interface { network = "default" }}Two labels uniquely identify a resource: the subtype (maps to the provider, e.g., google_compute_instance) and the identifier (user-defined, e.g., web). Two resources with the same subtype cannot share the same identifier.
Data Sources
Section titled “Data Sources”Data sources perform read-only lookups - they never create or destroy infrastructure. Use them to query existing resources and feed their attributes into your configuration dynamically.
data "google_compute_image" "ubuntu" { family = "ubuntu-2404-lts" project = "ubuntu-os-cloud"}
resource "google_compute_instance" "web" { boot_disk { initialize_params { image = data.google_compute_image.ubuntu.self_link # dynamic lookup } }}Why data sources over hardcoded values? An AMI or image ID changes every time a new version is released. Hardcoding forces manual updates; a data source always fetches the latest.
Filtering Data Sources
Section titled “Filtering Data Sources”Data sources accept filter subblocks, which can be stacked to narrow searches:
data "aws_ami" "ubuntu" { most_recent = true owners = ["099720109477"] # Canonical's AWS account
filter { name = "virtualization-type" values = ["hvm"] } filter { name = "name" values = ["ubuntu/images/hvm-ssd/ubuntu-*-22.04-amd64-server-*"] }}Chaining Data Sources
Section titled “Chaining Data Sources”Attributes from one data source feed directly into another, creating an implicit dependency chain:
data "aws_vpc" "main" { default = true}
data "aws_subnets" "public" { filter { name = "vpcId" values = [data.aws_vpc.main.id] # chain: VPC ID → subnet lookup }}Terraform resolves the VPC data source first automatically.
Missing Data Behaviour
Section titled “Missing Data Behaviour”| Data source type | No match result |
|---|---|
Singular lookup (aws_ami, aws_vpc) | Error - halts the plan |
List/set lookup (aws_subnets, aws_instances) | Returns empty list - no error |
Meta-Arguments
Section titled “Meta-Arguments”Meta-arguments are built directly into HCL - they are universal, not vendor-specific, and change how Terraform processes a block rather than what infrastructure it creates.
provider
Section titled “provider”Selects a named provider alias for a specific resource or data source, rather than using the default provider configuration:
resource "aws_instance" "replica" { provider = aws.west ami = "ami-0abc123" instance_type = "t3.micro"}depends_on
Section titled “depends_on”Terraform maps dependencies automatically through attribute references (implicit dependencies). Use depends_on when a resource relies on another but doesn’t reference any of its attributes.
A classic example: an AWS NAT Gateway needs an active Internet Gateway before it can launch, but references no IGW attributes directly.
resource "aws_nat_gateway" "main" { allocation_id = aws_eip.nat.id subnet_id = aws_subnet.public.id
depends_on = [aws_internet_gateway.main] # explicit dependency}When depends_on is applied to a module block, the dependency cascades to every resource inside that module.
lifecycle
Section titled “lifecycle”The lifecycle subblock controls replacement and update behaviour. It can only appear once per block and must always be placed at the end of the block.
resource "aws_instance" "web" { ami = data.aws_ami.ubuntu.id instance_type = "t3.micro"
lifecycle { create_before_destroy = true ignore_changes = [tags, ami] }}| Rule | Effect |
|---|---|
create_before_destroy = true | Creates the replacement before destroying the old resource. Critical for HA environments where destroying first would cause downtime. |
prevent_destroy = true | Any plan attempting to destroy this resource fails immediately. ⚠️ Use sparingly - it can be bypassed by deleting the resource block entirely. |
ignore_changes = [attr, ...] | Ignores external attribute changes. Use all to make the resource fully read-only after creation. Common for tags managed by orchestration systems or AMI IDs that update frequently. |
replace_triggered_by = [ref] | Forces a complete replacement (not just an update) when the referenced attribute or resource changes. |
Backends and State Configuration
Section titled “Backends and State Configuration”The terraform settings block also configures where Terraform stores workspace state.
terraform { required_version = ">= 1.6"
required_providers { ... }
backend "s3" { bucket = "my-tf-state" key = "prod/terraform.tfstate" region = "us-east-1" }}backend Block
Section titled “backend Block”The classic remote state configuration. Supported backends include AWS S3, Google Cloud Storage, AzureRM, Consul, and http (for custom REST implementations).
Every backend has its own argument requirements - check the provider documentation.
cloud Block
Section titled “cloud Block”A newer alternative to backend, designed for Terraform Cloud and Terraform Enterprise:
terraform { cloud { organization = "my-org" workspaces { name = "production" } }}Use either backend or cloud - not both.
Modules
Section titled “Modules”Modules are collections of data sources, resources, configuration files, and templates bundled into reusable components. They act like higher-level abstractions built on top of provider resources - and unlike providers, modules are written entirely in HCL.
module "vpc" { source = "terraform-aws-modules/vpc/aws" version = "~> 5.0"
name = "production-vpc" cidr = "10.0.0.0/16" azs = ["us-east-1a", "us-east-1b"]}Unlike resource arguments (dictated by the provider), module arguments are defined by the module author and declared inside the module using variable blocks. Modules export data back to the caller via output blocks.
Module Types
Section titled “Module Types”| Type | Description |
|---|---|
| Root module | The entry point of every Terraform project - configures required_providers and calls other modules |
| Shared module | A reusable module sourced from a registry, Git repo, or internal registry |
| Submodule | Distributed inside a parent module (typically in a modules/ directory) to break complex functionality into manageable pieces |
Module File Structure
Section titled “Module File Structure”The standard convention for module layout:
my-module/├── main.tf # Core resources├── variables.tf # Input variable declarations├── outputs.tf # Output value declarations└── README.md # Documentation (auto-parsed by registries)Module-Specific Meta-Arguments
Section titled “Module-Specific Meta-Arguments”| Argument | Purpose |
|---|---|
source | Required. Where to find the module - registry URL, local path, or Git ref. |
version | Acceptable version range when sourcing from a registry. Always pin this. |
providers | Pass provider aliases from the outer workspace into the module. |
Cascading Dependencies
Section titled “Cascading Dependencies”If depends_on is applied to a module block, the dependency propagates to every resource inside that module automatically.
Refactoring Code into a Reusable Module
Section titled “Refactoring Code into a Reusable Module”Turning a hard-coded root module into a reusable shared module follows five steps:
- Remove
providerblocks - shared modules declarerequired_providersbut never configure providers directly - Parameterise hard-coded values - replace static values with
variableblocks, addtypeconstraints andvalidationsubblocks - Expose data via outputs - add
outputs.tfso callers can link this module to other modules - Test locally - create an
examples/folder with a temporary root module that callssource = "../"and has the requiredproviderblock - Publish - name the repository
terraform-<PROVIDER>-<NAME>(e.g.terraform-aws-instance); other developers reference it by Git URL or registry path
Refactoring Blocks
Section titled “Refactoring Blocks”Terraform provides dedicated blocks for safely restructuring code without destroying live infrastructure.
import
Section titled “import”Brings existing infrastructure (created manually via console or CLI) under Terraform management:
import { id = "i-0abc123456" # the cloud resource ID to = aws_instance.web}Advantages over terraform import CLI command:
- Changes are visible in
terraform planbefore being applied - Easier to automate across multiple environments (staging, production)
- From Terraform v1.6,
idcan use variables or data source attributes (no hardcoding required)
Tells Terraform a resource was renamed or moved into a module, preventing accidental destruction:
moved { from = aws_instance.old_name to = aws_instance.new_name}Unlike import blocks, moved blocks are safe to leave in permanently. In a new environment where no matching resource exists, Terraform simply creates a fresh one - no harm done. This makes moved blocks safe to ship inside reusable modules.
removed (Terraform v1.7+)
Section titled “removed (Terraform v1.7+)”Removes a resource from Terraform’s state management without destroying the running infrastructure:
removed { from = aws_instance.legacy
lifecycle { destroy = false }}Use this when you want to “forget” a resource - stop tracking it in state - without tearing it down.
The Core Workflow
Section titled “The Core Workflow”Before deploying infrastructure, authenticate with your cloud provider. Each provider handles authentication differently - for GCP, set GOOGLE_APPLICATION_CREDENTIALS; for AWS, run aws configure or export AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY.
# 1 - Initialize the workspace (download providers and modules)terraform init
# 2 - Preview what Terraform will doterraform plan
# 3 - Apply changesterraform apply| Command | What it does |
|---|---|
terraform init | Downloads providers and modules; generates .terraform.lock.hcl |
terraform plan | Builds the execution plan; shows + create, ~ update, - destroy actions |
terraform apply | Executes the plan; prints a summary on completion |
terraform fmt | Auto-formats code to style guidelines |
Variables, Outputs, and Locals
Section titled “Variables, Outputs, and Locals”Terraform variables are constants - a variable’s value is set once at the start of a run and never changes during execution. There are three kinds, each with a different scope.
Input Variables
Section titled “Input Variables”Input variables are declared with variable blocks (conventionally in variables.tf) and act as parameters for a module:
variable "instance_type" { type = string default = "t3.micro" description = "EC2 instance type for the compute node" nullable = false
validation { condition = contains(["t3.micro", "t3.small", "t3.medium"], var.instance_type) error_message = "Must be one of: t3.micro, t3.small, t3.medium." }}| Argument | Purpose |
|---|---|
type | Enforces data type - stops execution early with a clear error if the wrong type is passed |
default | Fallback value - makes the variable optional for callers |
description | Documentation shown in plan output and registry UIs |
nullable | Whether null is an accepted value (defaults to true) |
validation | Custom correctness checks before the plan runs |
Input values are set by callers using module block arguments, .tfvars files, environment variables (TF_VAR_<name>), or CLI flags (-var).
Output Values
Section titled “Output Values”Output values are declared with output blocks (conventionally in outputs.tf) and act as the return values of a module:
output "instance_ip" { value = aws_instance.web.public_ip description = "Public IP of the web server" depends_on = [aws_eip_association.web]}| Argument | Purpose |
|---|---|
value | The data to return - usually an attribute of a created resource |
description | Documents what this output represents |
depends_on | Forces the output to wait for another resource before resolving |
sensitive | Masks the value in CLI output and logs |
Outputs are how independent modules are linked together - the public_ip output of a compute module can become the target input of a firewall module.
Local Variables
Section titled “Local Variables”Locals are internal intermediate values scoped entirely within the module. They cannot be accessed from outside:
locals { full_name = "${var.project}-${var.environment}" common_tags = { Project = var.project Environment = var.environment ManagedBy = "terraform" }}
resource "aws_instance" "web" { tags = local.common_tags}Unlike other block types, locals blocks take no label and can appear multiple times across multiple files in the same module.
Handling Sensitive Values
Section titled “Handling Sensitive Values”Mark any input or output containing secrets as sensitive = true:
variable "db_password" { type = string sensitive = true}This masks the value in all CLI output and logs. Sensitivity propagates automatically - any local or output derived from a sensitive value is also masked.
Value Types
Section titled “Value Types”Specifying types makes your code significantly more robust. If an incorrect type is passed, Terraform halts before making any changes and provides a clear error message.
Primitive Types
Section titled “Primitive Types”| Type | Keyword | Notes |
|---|---|---|
| String | string | Unicode-encoded; supports string interpolation ("${var.name}") |
| Number | number | Single type covers integers, floats, and negatives |
| Boolean | bool | true or false |
Collection Types - Sequences
Section titled “Collection Types - Sequences”| Type | Keyword | Key characteristic |
|---|---|---|
| List | list(type) | Ordered; zero-indexed. All elements must be the same type. |
| Set | set(type) | Unordered; automatically deduplicates. All elements same type. |
| Tuple | tuple([type, type, ...]) | Fixed length; elements may be different types in defined positions. |
Collection Types - Key-Value
Section titled “Collection Types - Key-Value”| Type | Keyword | Key characteristic |
|---|---|---|
| Map | map(type) | Arbitrary string keys; all values the same type. Ideal for tags. |
| Object | object({key=type,...}) | Strict schema; each key can hold a different type. Supports optional() keys with defaults. Extra keys are dropped. |
Special Types
Section titled “Special Types”| Type | Description |
|---|---|
null | Represents “not set”. Useful as a default for optional inputs. Cannot be used as a type constraint. |
any | Accepts any type (the default if no type is declared). In collections (list(any)), all elements must still share the same type. |
Expressions and Operators
Section titled “Expressions and Operators”Operators
Section titled “Operators”Mathematical - standard arithmetic supported natively; use Terraform functions for anything more complex (exponents, rounding):
count = var.instance_count * 2Comparison - ==, !=, <, <=, >, >=
Boolean - || (OR), && (AND), ! (NOT)
Operator precedence (high → low):
!, - (negation / unary minus)*, /, %+, ->, >=, <, <===, !=&&||Use parentheses liberally - they make intent explicit and reduce editing errors.
Conditional Expressions (Ternary)
Section titled “Conditional Expressions (Ternary)”count = var.enable_monitoring ? 1 : 0The condition evaluates to a boolean; if true the second operand is returned, otherwise the third.
Strings and Templates
Section titled “Strings and Templates”Interpolation embeds expressions directly into strings:
name = "${var.project}-${var.environment}-web"file function - load a large static string from a file:
user_data = file("${path.module}/scripts/init.sh")Path variables: path.module (current module), path.root (root module), path.cwd (working directory).
templatefile function - inject dynamic values into a template file:
user_data = templatefile("${path.module}/templates/nginx.conf.tpl", { server_name = var.domain port = var.port})Terraform’s template language also supports logic inside %{...} directives:
%{ if var.enable_https }listen 443 ssl;%{ endif }%{ for host in var.allowed_hosts }allow ${host};%{ endfor }Regular Expressions
Section titled “Regular Expressions”Terraform uses Golang regex syntax (use Regex101 in Golang mode to build patterns).
| Function | Behaviour on no match | Return type |
|---|---|---|
regex(pattern, str) | Error | String, list (unnamed groups), or map (named groups) |
regexall(pattern, str) | Empty list | List of strings / list of lists / list of maps |
replace(str, pattern, replacement) | Original string | String |
Capture group references in replace: unnamed → $1, $2; named → $groupname.
Type Conversion
Section titled “Type Conversion”Implicit conversion happens automatically for simple cases (string "true" ↔ bool true), but not inside equality operators.
Explicit conversion functions - use to normalise module outputs:
tostring(42) # "42"tonumber("3.14") # 3.14tobool("true") # truetolist(toset(["a","b"])) # list from settoset(["a","b","a"]) # {"a","b"} - deduplicatesSensitive / nonsensitive - these functions return a new value with the sensitivity flag changed (they do not mutate the original):
locals { safe_url = nonsensitive("https://${var.api_key}@api.example.com")}try and can
Section titled “try and can”Use these only for expected failures - never to hide genuine bugs.
# try - return the first expression that doesn't errorlocals { port = try(var.config.port, 8080) # fall back to 8080 if .port doesn't exist}
# can - convert success/failure into a booleanvariable "subnet_id" { validation { condition = can(regex("^subnet-", var.subnet_id)) error_message = "Must be a valid AWS subnet ID (subnet-xxxxxxxx)." }}| Function | Returns |
|---|---|
try(expr1, expr2, ...) | First non-erroring expression value |
can(expr) | true if expression succeeds, false if it errors |
Best used inside validation blocks and output blocks to normalise data.
Functions
Section titled “Functions”Terraform functions are pure data transformers - they take inputs, return a value, and never create or modify infrastructure.
functions_example = merge( var.common_tags, { Name = "${var.name_prefix}-${count.index}" })Standard library categories:
| Category | Example functions |
|---|---|
| Numeric | abs, ceil, floor, max, min, pow |
| String | format, lower, upper, trim, split, join |
| Collection | merge, concat, flatten, distinct, lookup, keys, values |
| Encoding | jsonencode, jsondecode, yamlencode, base64encode |
| Filesystem | file, templatefile, fileset |
| Date & Time | timestamp, formatdate, timeadd |
| Hash & Crypto | sha256, md5, bcrypt |
| IP Network | cidrsubnet, cidrhost, cidrnetmask |
| Type Conversion | tostring, tonumber, tobool, tolist, toset, tomap |
Iterations: count and for_each
Section titled “Iterations: count and for_each”Because Terraform is declarative, standard loops don’t apply. Instead, two meta-parameters create multiple instances of a block, and two expressions transform groups of data.
count and for_each are mutually exclusive - you cannot use both on the same block.
Accepts an integer and creates that many copies. Best for simple multiplication or boolean toggles:
resource "aws_instance" "workers" { count = var.instance_count ami = data.aws_ami.ubuntu.id instance_type = "t3.micro" tags = { Name = "worker-${count.index}" } # count.index starts at 0}
# Boolean toggle patternresource "aws_cloudwatch_log_group" "app" { count = var.enable_logging ? 1 : 0 name = "/app/logs"}Accessing count-created resources: aws_instance.workers[0].public_ip, or all IPs with aws_instance.workers[*].public_ip.
for_each
Section titled “for_each”Accepts an object, map, or set and creates one instance per element, each with its own configuration:
resource "aws_s3_bucket" "env_buckets" { for_each = toset(["dev", "staging", "prod"]) bucket = "my-app-${each.key}"}Inside a for_each block, each.key and each.value are available:
| Input type | each.key | each.value |
|---|---|---|
| Map/object | The map key | The map value |
| Set | The element value | The element value |
Critical constraint: Values passed to count or for_each must be known at plan time. You cannot use impure functions (uuid(), timestamp()) or attributes that are only computed after resource creation. If you hit this constraint with for_each, refactor to use count + count.index instead.
for Expressions
Section titled “for Expressions”Transforms a collection into a new collection (does not create resources):
# List output - square bracketslocals { upper_names = [for name in var.names : upper(name)]}
# Map/object output - curly brackets with => operatorlocals { name_map = { for id, name in var.servers : id => upper(name) }}
# With filteringlocals { active_only = [for s in var.servers : s if s.enabled]}
# Grouping mode - accumulate multiple values under the same keylocals { by_region = { for s in var.servers : s.region => s.name... } # note the ellipsis}| Syntax | Input | Output |
|---|---|---|
[for v in list : expr] | list/set | list |
{for k, v in map : key => val} | map/object | object |
... if condition | any | filtered collection |
value... (grouping mode) | any | object with list values |
Splat Expression ([*])
Section titled “Splat Expression ([*])”An elegant shorthand for the most common for pattern - extracting one attribute from a count or for_each result:
output "worker_ips" { value = aws_instance.workers[*].public_ip}
# Also converts a single nullable value into a listlocals { maybe_list = var.optional_value[*] # [] if null, [value] otherwise}Dynamic Blocks
Section titled “Dynamic Blocks”Standard arguments can only appear once per block. Subblocks (like ingress rules or filter clauses) can repeat - but when the number of repetitions depends on user input, you need dynamic blocks:
resource "aws_security_group" "web" { name = "web-sg"
# Static subblock - always present egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] }
# Dynamic subblock - one ingress rule per port dynamic "ingress" { for_each = var.allowed_ports content { from_port = ingress.value # iterator named after the subblock type to_port = ingress.value protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] } }}Two structural rules unique to dynamic blocks:
- Custom iterator name - the iterator variable is named after the subblock type (
ingress.value, noteach.value) contentblock required - all arguments go inside a nestedcontent { }block
Toggling a Dynamic Block On/Off
Section titled “Toggling a Dynamic Block On/Off”Use a conditional that switches between an empty list (off) and a single-element list (on):
dynamic "ingress" { for_each = var.enable_https ? ["placeholder"] : [] content { from_port = 443 to_port = 443 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] }}The string "placeholder" is never used inside content - it just provides one iteration to create one block.
Static and dynamic subblocks of the same type can coexist in the same resource - a common pattern is hardcoding an egress rule while generating ingress rules dynamically.