Terraform Loops and Iteration — Summary Table
| # | Option / Method | Purpose | Input | Common use case | Creates resources? |
|---|---|---|---|---|---|
| 1 | count | Repeats a resource or module a specified number of times | Number | Create 3 EC2 instances or Azure VMs | Yes, when used on a resource/module |
| 2 | for_each | Repeats resources or modules using named keys | Map or set of strings | Create VMs for dev, test, and prod | Yes, when used on a resource/module |
| 3 | for expression — list | Transforms elements into a new list | List, tuple, or set | Convert environment names to uppercase | No |
| 4 | for expression — map | Transforms elements into a map/object | Collection | Build a map of application names and settings | No |
| 5 | for expression with if | Filters elements based on a condition | Collection | Select only enabled applications | No, but the result can drive resource creation |
| 6 | Nested for expressions | Iterates over nested collections | Nested lists, maps, or objects | Generate application/environment combinations | No, but the result can drive resource creation |
| 7 | dynamic block | Generates repeated nested configuration blocks | Collection via for_each | Generate AWS security group ingress rules | No new top-level resources |
| 8 | flatten() with iteration | Converts nested lists into a single list | Nested lists | Flatten application/environment combinations | No |
| 9 | setproduct() with iteration | Generates combinations from collections | Two or more collections | Create resources for every app/environment combination | No, but the result can drive resource creation |
| 10 | toset() with for_each | Converts a list to a set of unique strings | List | Create one resource per unique application name | Yes, through for_each |
| 11 | keys() / values() | Extracts map keys or values | Map | Prepare data for iteration or outputs | No |
| 12 | Module count | Repeats module instances numerically | Number | Optionally deploy a module | Yes, creates module instances |
| 13 | Module for_each | Repeats module instances by key | Map or set of strings | Deploy a module for each application | Yes, creates module instances |
Complete Guide to count, for_each, for Expressions, and Dynamic Blocks
Audience: Beginners through advanced DevOps / cloud engineers
Cloud examples: AWS EC2 and Azure Linux Virtual Machines
Terraform version: Terraform 1.3+ recommended
Approach: Each hands-on cloud lab is a standalone main.tf, with run steps, expected outcomes, and cleanup.
Cost and security: Cloud labs create billable resources. Review plans before applying and destroy resources when finished. SSH rules should be limited to your own public IPv4 address using CIDR notation such as
203.0.113.10/32. Do not open SSH to0.0.0.0/0in production. Never put cloud credentials or private keys in Terraform files.
Contents
- Learning goals
- Before you start
- The five main iteration patterns
- Lab 1 —
count: create multiple instances - Lab 2 —
for_each: create named instances - Lab 3 —
forexpressions: transform collections - Lab 4 — Filtering with
for ... if - Lab 5 —
dynamicblocks - Lab 6 — Nested iteration and
flatten - Lab 7 —
setproductfor combinations - Lab 8 — Iterating over objects and maps
- Lab 9 — Conditional iteration
- Lab 10 — Iterating over modules
countvsfor_each- Common errors and troubleshooting
- Best practices
- Quick reference
- Practice exercises
1. Learning goals
After this guide, you should be able to:
- Repeat resources with
countorfor_each. - Transform a list, map, or object with a
forexpression. - Filter collections with an
ifclause. - Generate repeated nested blocks with
dynamic. - Flatten nested collections and generate combinations.
- Repeat modules with
countorfor_each. - Understand resource addresses, plan-time requirements, and common iteration errors.
- Run and clean up practical AWS and Azure labs.
2. Before you start
Install Terraform and the relevant CLI.
AWS
Configure credentials using an AWS profile, environment variables, or an IAM role. Confirm the identity:
terraform version
aws sts get-caller-identity
Code language: JavaScript (javascript)
You need permissions to create and delete EC2 instances and security groups. Use a valid Ubuntu AMI ID from the same AWS region as the provider.
Azure
Sign in and select the right subscription:
terraform version
az login
az account show --output table
az account set --subscription "<SUBSCRIPTION_ID_OR_NAME>"
Code language: HTML, XML (xml)
You need permissions to create and delete resource groups, networking, public IPs, and Linux VMs. Regional quota, policy, and VM-size availability can prevent creation.
General workflow
For each lab, use a separate empty directory and save that lab’s code as main.tf.
terraform init
terraform fmt
terraform validate
terraform plan
terraform apply
terraform output
terraform destroy
Terraform prompts before applying or destroying unless you pass an automatic approval flag. For learning, review the plan and type the confirmation yourself.
Azure SSH key: Most Azure labs expect the contents of a public key, for example:
cat ~/.ssh/id_ed25519.pub
Code language: JavaScript (javascript)
If no key exists, create one with ssh-keygen -t ed25519. Pass the public key to Terraform, never the private key.
3. The five main iteration patterns
Pattern Main purpose Typical use
count Create a numeric number Three identical test of resource/module instances, or one instances optional instance
for_each Create instances from a One VM per named map or set of strings application
for expression Transform a collection Convert names to into another collection uppercase or build a map
for ... if Filter a collection Keep only enabled applications
dynamic block Generate repeated AWS security-group nested blocks inside ingress rules one resource
Supporting tools include flatten(), toset(), keys(), values(), zipmap(), and setproduct(). These are functions that help prepare collections for iteration.
4. Lab 1 — count: create multiple instances
How count works
count accepts a non-negative integer. Terraform creates that many instances, and each instance has a numeric index:
count.indexstarts at0.- With
count = 3, the addresses areresource.example[0],[1], and[2]. resource.example[*].idcollects IDs from all counted instances.
Use count when instances are interchangeable or when a resource should exist only zero or one times. If removing an item from a list shifts indexes, Terraform can plan unexpected replacements.
4A. AWS — create three EC2 instances
Save as main.tf:
terraform {
required_version = ">= 1.3.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
variable "aws_region" {
type = string
default = "ap-northeast-1"
}
variable "instance_count" {
type = number
default = 3
validation {
condition = var.instance_count >= 0 && var.instance_count <= 5
error_message = "instance_count must be between 0 and 5 for this lab."
}
}
variable "ami_id" {
type = string
description = "Valid Ubuntu AMI ID in the selected AWS region."
}
variable "ssh_cidr" {
type = string
description = "Your public IPv4 address in CIDR notation, e.g. 203.0.113.10/32."
}
resource "aws_security_group" "lab" {
name_prefix = "tf-count-lab-"
description = "SSH restricted to the student's public IP"
ingress {
description = "SSH from student IP only"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [var.ssh_cidr]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "tf-count-lab-sg"
}
}
resource "aws_instance" "web" {
count = var.instance_count
ami = var.ami_id
instance_type = "t3.micro"
vpc_security_group_ids = [aws_security_group.lab.id]
tags = {
Name = "count-web-${count.index + 1}"
Index = tostring(count.index)
}
}
output "instance_ids" {
value = aws_instance.web[*].id
}
output "instance_public_ips" {
value = aws_instance.web[*].public_ip
}
output "created_count" {
value = length(aws_instance.web)
}
Code language: JavaScript (javascript)
Run:
terraform init
terraform fmt
terraform validate
terraform plan \
-var="ami_id=ami-REPLACE_WITH_VALID_UBUNTU_AMI" \
-var="ssh_cidr=YOUR_PUBLIC_IP/32"
terraform apply \
-var="ami_id=ami-REPLACE_WITH_VALID_UBUNTU_AMI" \
-var="ssh_cidr=YOUR_PUBLIC_IP/32"
terraform output
Code language: JavaScript (javascript)
To test zero instances, use -var="instance_count=0" with plan and then apply. The security group remains because it is not counted. Destroy the lab when finished, using the same required variables.
Expected: The default creates three EC2 instances. Output lists their IDs and public IPs. AMI IDs are regional; get a current Ubuntu AMI for the configured region before applying.
4B. Azure — create three Linux VMs
This example includes a resource group, VNet, subnet, public IPs, NSG, NICs, NSG association, and Linux VMs.
terraform {
required_version = ">= 1.3.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
provider "azurerm" {
features {}
resource_provider_registrations = "none"
}
variable "location" {
type = string
default = "Japan East"
}
variable "vm_count" {
type = number
default = 3
validation {
condition = var.vm_count >= 0 && var.vm_count <= 5
error_message = "vm_count must be between 0 and 5 for this lab."
}
}
variable "admin_username" {
type = string
default = "azureuser"
}
variable "ssh_public_key" {
type = string
description = "Contents of your SSH public key."
}
variable "ssh_cidr" {
type = string
description = "Your public IPv4 address in CIDR notation, e.g. 203.0.113.10/32."
}
locals {
prefix = "tfcount"
}
resource "azurerm_resource_group" "lab" {
name = "${local.prefix}-rg"
location = var.location
}
resource "azurerm_virtual_network" "lab" {
name = "${local.prefix}-vnet"
address_space = ["10.100.0.0/16"]
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
}
resource "azurerm_subnet" "lab" {
name = "default"
resource_group_name = azurerm_resource_group.lab.name
virtual_network_name = azurerm_virtual_network.lab.name
address_prefixes = ["10.100.1.0/24"]
}
resource "azurerm_network_security_group" "lab" {
name = "${local.prefix}-nsg"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
security_rule {
name = "AllowSSHFromStudent"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = var.ssh_cidr
destination_address_prefix = "*"
}
}
resource "azurerm_public_ip" "vm" {
count = var.vm_count
name = "${local.prefix}-pip-${count.index + 1}"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
allocation_method = "Static"
sku = "Standard"
}
resource "azurerm_network_interface" "vm" {
count = var.vm_count
name = "${local.prefix}-nic-${count.index + 1}"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
ip_configuration {
name = "internal"
subnet_id = azurerm_subnet.lab.id
private_ip_address_allocation = "Dynamic"
public_ip_address_id = azurerm_public_ip.vm[count.index].id
}
}
resource "azurerm_network_interface_security_group_association" "vm" {
count = var.vm_count
network_interface_id = azurerm_network_interface.vm[count.index].id
network_security_group_id = azurerm_network_security_group.lab.id
}
resource "azurerm_linux_virtual_machine" "vm" {
count = var.vm_count
name = "${local.prefix}-vm-${count.index + 1}"
computer_name = "${local.prefix}-${count.index + 1}"
resource_group_name = azurerm_resource_group.lab.name
location = azurerm_resource_group.lab.location
size = "Standard_B1s"
admin_username = var.admin_username
network_interface_ids = [
azurerm_network_interface.vm[count.index].id
]
disable_password_authentication = true
admin_ssh_key {
username = var.admin_username
public_key = var.ssh_public_key
}
os_disk {
caching = "ReadWrite"
storage_account_type = "Standard_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts-gen2"
version = "latest"
}
tags = {
Lab = "count"
Index = tostring(count.index)
}
}
output "vm_ids" {
value = azurerm_linux_virtual_machine.vm[*].id
}
output "public_ips" {
value = azurerm_public_ip.vm[*].ip_address
}
output "created_count" {
value = length(azurerm_linux_virtual_machine.vm)
}
Code language: JavaScript (javascript)
Run:
terraform init
terraform fmt
terraform validate
terraform plan \
-var="ssh_public_key=$(cat ~/.ssh/id_ed25519.pub)" \
-var="ssh_cidr=YOUR_PUBLIC_IP/32"
terraform apply \
-var="ssh_public_key=$(cat ~/.ssh/id_ed25519.pub)" \
-var="ssh_cidr=YOUR_PUBLIC_IP/32"
terraform output
Code language: JavaScript (javascript)
Try -var="vm_count=0" to see the VMs, NICs, and public IPs removed while the shared network remains. Use terraform destroy when finished. Verify VM size availability and quota for your subscription and region.
5. Lab 2 — for_each: create named instances
How for_each works
for_each accepts a map or a set of strings. Terraform creates one instance for every key:
- Map:
each.keyis the map key;each.valueis the associated value. - Set of strings:
each.keyandeach.valueare the current string. - Addresses use stable keys, for example
aws_instance.app["frontend"].
This is usually the better choice when each resource has a meaningful identity, such as an application, environment, or subnet.
5A. AWS — create one EC2 instance per application
terraform {
required_version = ">= 1.3.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
variable "aws_region" {
type = string
default = "ap-northeast-1"
}
variable "ami_id" {
type = string
}
variable "ssh_cidr" {
type = string
}
variable "applications" {
type = map(object({
instance_type = string
environment = string
}))
default = {
frontend = { instance_type = "t3.micro", environment = "dev" }
backend = { instance_type = "t3.micro", environment = "test" }
worker = { instance_type = "t3.micro", environment = "dev" }
}
}
resource "aws_security_group" "lab" {
name_prefix = "tf-foreach-lab-"
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [var.ssh_cidr]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_instance" "app" {
for_each = var.applications
ami = var.ami_id
instance_type = each.value.instance_type
vpc_security_group_ids = [aws_security_group.lab.id]
tags = {
Name = "app-${each.key}"
Application = each.key
Environment = each.value.environment
}
}
output "instance_ids_by_application" {
value = { for name, instance in aws_instance.app : name => instance.id }
}
output "public_ips_by_application" {
value = { for name, instance in aws_instance.app : name => instance.public_ip }
}
Code language: JavaScript (javascript)
Run:
terraform init
terraform fmt
terraform validate
terraform plan \
-var="ami_id=ami-REPLACE_WITH_VALID_UBUNTU_AMI" \
-var="ssh_cidr=YOUR_PUBLIC_IP/32"
terraform apply \
-var="ami_id=ami-REPLACE_WITH_VALID_UBUNTU_AMI" \
-var="ssh_cidr=YOUR_PUBLIC_IP/32"
terraform output
terraform destroy \
-var="ami_id=ami-REPLACE_WITH_VALID_UBUNTU_AMI" \
-var="ssh_cidr=YOUR_PUBLIC_IP/32"
Code language: JavaScript (javascript)
Expected: Three EC2 instances with keys frontend, backend, and worker. The output maps application names to IDs and IPs.
5B. Azure — create one Linux VM per application
terraform {
required_version = ">= 1.3.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
provider "azurerm" {
features {}
resource_provider_registrations = "none"
}
variable "location" {
type = string
default = "Japan East"
}
variable "admin_username" {
type = string
default = "azureuser"
}
variable "ssh_public_key" {
type = string
}
variable "ssh_cidr" {
type = string
}
variable "applications" {
type = map(object({
vm_size = string
environment = string
}))
default = {
frontend = { vm_size = "Standard_B1s", environment = "dev" }
backend = { vm_size = "Standard_B1s", environment = "test" }
worker = { vm_size = "Standard_B1s", environment = "dev" }
}
}
locals {
prefix = "tfforeach"
}
resource "azurerm_resource_group" "lab" {
name = "${local.prefix}-rg"
location = var.location
}
resource "azurerm_virtual_network" "lab" {
name = "${local.prefix}-vnet"
address_space = ["10.110.0.0/16"]
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
}
resource "azurerm_subnet" "lab" {
name = "default"
resource_group_name = azurerm_resource_group.lab.name
virtual_network_name = azurerm_virtual_network.lab.name
address_prefixes = ["10.110.1.0/24"]
}
resource "azurerm_network_security_group" "lab" {
name = "${local.prefix}-nsg"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
security_rule {
name = "AllowSSHFromStudent"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = var.ssh_cidr
destination_address_prefix = "*"
}
}
resource "azurerm_public_ip" "app" {
for_each = var.applications
name = "${local.prefix}-${each.key}-pip"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
allocation_method = "Static"
sku = "Standard"
}
resource "azurerm_network_interface" "app" {
for_each = var.applications
name = "${local.prefix}-${each.key}-nic"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
ip_configuration {
name = "internal"
subnet_id = azurerm_subnet.lab.id
private_ip_address_allocation = "Dynamic"
public_ip_address_id = azurerm_public_ip.app[each.key].id
}
}
resource "azurerm_network_interface_security_group_association" "app" {
for_each = var.applications
network_interface_id = azurerm_network_interface.app[each.key].id
network_security_group_id = azurerm_network_security_group.lab.id
}
resource "azurerm_linux_virtual_machine" "app" {
for_each = var.applications
name = "${local.prefix}-${each.key}-vm"
computer_name = "${local.prefix}-${each.key}"
resource_group_name = azurerm_resource_group.lab.name
location = azurerm_resource_group.lab.location
size = each.value.vm_size
admin_username = var.admin_username
network_interface_ids = [
azurerm_network_interface.app[each.key].id
]
disable_password_authentication = true
admin_ssh_key {
username = var.admin_username
public_key = var.ssh_public_key
}
os_disk {
caching = "ReadWrite"
storage_account_type = "Standard_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts-gen2"
version = "latest"
}
tags = {
Application = each.key
Environment = each.value.environment
}
}
output "vm_ids_by_application" {
value = { for name, vm in azurerm_linux_virtual_machine.app : name => vm.id }
}
output "public_ips_by_application" {
value = { for name, pip in azurerm_public_ip.app : name => pip.ip_address }
}
Code language: JavaScript (javascript)
Run:
terraform init
terraform fmt
terraform validate
terraform plan \
-var="ssh_public_key=$(cat ~/.ssh/id_ed25519.pub)" \
-var="ssh_cidr=YOUR_PUBLIC_IP/32"
terraform apply \
-var="ssh_public_key=$(cat ~/.ssh/id_ed25519.pub)" \
-var="ssh_cidr=YOUR_PUBLIC_IP/32"
terraform output
terraform destroy \
-var="ssh_public_key=$(cat ~/.ssh/id_ed25519.pub)" \
-var="ssh_cidr=YOUR_PUBLIC_IP/32"
Code language: JavaScript (javascript)
Expected: Three VMs, each with its own public IP, NIC, and stable key. Azure resource names have restrictions; keep names short and use supported characters.
6. Lab 3 — for expressions: transform collections
How for expressions work
A for expression reads items from a collection and builds a new list/tuple or map/object.
List result:
[for name in var.names : upper(name)]
Code language: JSON / JSON with Comments (json)
Map result:
{ for name in var.names : name => upper(name) }
Code language: JavaScript (javascript)
The for expression itself does not create cloud resources. It creates data that can be used by resources, outputs, or locals.
6A. AWS — build a map of instance metadata
This is a complete EC2 example that uses a for expression to prepare names and output the resources as a map.
terraform {
required_version = ">= 1.3.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
variable "aws_region" {
type = string
default = "ap-northeast-1"
}
variable "ami_id" {
type = string
}
variable "ssh_cidr" {
type = string
}
variable "names" {
type = list(string)
default = ["frontend", "backend", "worker"]
}
locals {
normalized_names = [for name in var.names : lower(trimspace(name))]
name_to_tag = { for name in local.normalized_names : name => upper(name) }
}
resource "aws_security_group" "lab" {
name_prefix = "tf-for-lab-"
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [var.ssh_cidr]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_instance" "app" {
for_each = toset(local.normalized_names)
ami = var.ami_id
instance_type = "t3.micro"
vpc_security_group_ids = [aws_security_group.lab.id]
tags = {
Name = "for-${each.key}"
NormalizedTag = local.name_to_tag[each.key]
}
}
output "normalized_names" {
value = local.normalized_names
}
output "uppercase_map" {
value = local.name_to_tag
}
output "instance_ids" {
value = { for name, instance in aws_instance.app : name => instance.id }
}
Code language: JavaScript (javascript)
Run with the standard AWS workflow and supply ami_id and ssh_cidr. Change the names list to experiment. Since this lab uses a set for for_each, duplicate names collapse to one key. Keep names unique to avoid confusing results.
6B. Azure — transform names and create VMs from the resulting collection
terraform {
required_version = ">= 1.3.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
provider "azurerm" {
features {}
resource_provider_registrations = "none"
}
variable "location" {
type = string
default = "Japan East"
}
variable "admin_username" {
type = string
default = "azureuser"
}
variable "ssh_public_key" {
type = string
}
variable "ssh_cidr" {
type = string
}
variable "names" {
type = list(string)
default = ["frontend", "backend", "worker"]
}
locals {
normalized_names = [for name in var.names : lower(trimspace(name))]
name_to_tag = { for name in local.normalized_names : name => upper(name) }
prefix = "tffor"
}
resource "azurerm_resource_group" "lab" {
name = "${local.prefix}-rg"
location = var.location
}
resource "azurerm_virtual_network" "lab" {
name = "${local.prefix}-vnet"
address_space = ["10.120.0.0/16"]
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
}
resource "azurerm_subnet" "lab" {
name = "default"
resource_group_name = azurerm_resource_group.lab.name
virtual_network_name = azurerm_virtual_network.lab.name
address_prefixes = ["10.120.1.0/24"]
}
resource "azurerm_network_security_group" "lab" {
name = "${local.prefix}-nsg"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
security_rule {
name = "AllowSSHFromStudent"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = var.ssh_cidr
destination_address_prefix = "*"
}
}
resource "azurerm_public_ip" "app" {
for_each = toset(local.normalized_names)
name = "${local.prefix}-${each.key}-pip"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
allocation_method = "Static"
sku = "Standard"
}
resource "azurerm_network_interface" "app" {
for_each = toset(local.normalized_names)
name = "${local.prefix}-${each.key}-nic"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
ip_configuration {
name = "internal"
subnet_id = azurerm_subnet.lab.id
private_ip_address_allocation = "Dynamic"
public_ip_address_id = azurerm_public_ip.app[each.key].id
}
}
resource "azurerm_network_interface_security_group_association" "app" {
for_each = toset(local.normalized_names)
network_interface_id = azurerm_network_interface.app[each.key].id
network_security_group_id = azurerm_network_security_group.lab.id
}
resource "azurerm_linux_virtual_machine" "app" {
for_each = toset(local.normalized_names)
name = "${local.prefix}-${each.key}-vm"
computer_name = "${local.prefix}-${each.key}"
resource_group_name = azurerm_resource_group.lab.name
location = azurerm_resource_group.lab.location
size = "Standard_B1s"
admin_username = var.admin_username
network_interface_ids = [
azurerm_network_interface.app[each.key].id
]
disable_password_authentication = true
admin_ssh_key {
username = var.admin_username
public_key = var.ssh_public_key
}
os_disk {
caching = "ReadWrite"
storage_account_type = "Standard_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts-gen2"
version = "latest"
}
tags = {
Application = each.key
NormalizedTag = local.name_to_tag[each.key]
}
}
output "normalized_names" {
value = local.normalized_names
}
output "uppercase_map" {
value = local.name_to_tag
}
output "public_ips" {
value = { for name, pip in azurerm_public_ip.app : name => pip.ip_address }
}
Code language: JavaScript (javascript)
Run with the standard Azure workflow and SSH inputs. The for expressions prepare the normalized list and map; for_each then creates one VM per unique normalized name.
7. Lab 4 — Filtering with for ... if
Concept
The if at the end of a for expression filters elements. Only items for which the condition is true appear in the resulting collection.
Example:
locals {
enabled_apps = {
for name, app in var.applications : name => app
if app.enabled
}
}
Code language: JavaScript (javascript)
7A. AWS — create instances only for enabled applications
terraform {
required_version = ">= 1.3.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
variable "aws_region" {
type = string
default = "ap-northeast-1"
}
variable "ami_id" {
type = string
}
variable "ssh_cidr" {
type = string
}
variable "applications" {
type = map(object({
enabled = bool
instance_type = string
}))
default = {
frontend = { enabled = true, instance_type = "t3.micro" }
backend = { enabled = true, instance_type = "t3.micro" }
sandbox = { enabled = false, instance_type = "t3.micro" }
}
}
locals {
enabled_apps = {
for name, app in var.applications : name => app
if app.enabled
}
}
resource "aws_security_group" "lab" {
name_prefix = "tf-filter-lab-"
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [var.ssh_cidr]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_instance" "app" {
for_each = local.enabled_apps
ami = var.ami_id
instance_type = each.value.instance_type
vpc_security_group_ids = [aws_security_group.lab.id]
tags = {
Name = "filter-${each.key}"
Application = each.key
}
}
output "enabled_app_names" {
value = keys(local.enabled_apps)
}
output "instance_ids" {
value = { for name, instance in aws_instance.app : name => instance.id }
}
Code language: JavaScript (javascript)
Run with the standard AWS workflow. The default creates frontend and backend; sandbox is filtered out. To enable it, change enabled = false to true in the variable default or provide a complete replacement map in a terraform.tfvars file.
7B. Azure — create VMs only for enabled applications
terraform {
required_version = ">= 1.3.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
provider "azurerm" {
features {}
resource_provider_registrations = "none"
}
variable "location" {
type = string
default = "Japan East"
}
variable "admin_username" {
type = string
default = "azureuser"
}
variable "ssh_public_key" {
type = string
}
variable "ssh_cidr" {
type = string
}
variable "applications" {
type = map(object({
enabled = bool
vm_size = string
}))
default = {
frontend = { enabled = true, vm_size = "Standard_B1s" }
backend = { enabled = true, vm_size = "Standard_B1s" }
sandbox = { enabled = false, vm_size = "Standard_B1s" }
}
}
locals {
prefix = "tffilter"
enabled_apps = {
for name, app in var.applications : name => app
if app.enabled
}
}
resource "azurerm_resource_group" "lab" {
name = "${local.prefix}-rg"
location = var.location
}
resource "azurerm_virtual_network" "lab" {
name = "${local.prefix}-vnet"
address_space = ["10.130.0.0/16"]
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
}
resource "azurerm_subnet" "lab" {
name = "default"
resource_group_name = azurerm_resource_group.lab.name
virtual_network_name = azurerm_virtual_network.lab.name
address_prefixes = ["10.130.1.0/24"]
}
resource "azurerm_network_security_group" "lab" {
name = "${local.prefix}-nsg"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
security_rule {
name = "AllowSSHFromStudent"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = var.ssh_cidr
destination_address_prefix = "*"
}
}
resource "azurerm_public_ip" "app" {
for_each = local.enabled_apps
name = "${local.prefix}-${each.key}-pip"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
allocation_method = "Static"
sku = "Standard"
}
resource "azurerm_network_interface" "app" {
for_each = local.enabled_apps
name = "${local.prefix}-${each.key}-nic"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
ip_configuration {
name = "internal"
subnet_id = azurerm_subnet.lab.id
private_ip_address_allocation = "Dynamic"
public_ip_address_id = azurerm_public_ip.app[each.key].id
}
}
resource "azurerm_network_interface_security_group_association" "app" {
for_each = local.enabled_apps
network_interface_id = azurerm_network_interface.app[each.key].id
network_security_group_id = azurerm_network_security_group.lab.id
}
resource "azurerm_linux_virtual_machine" "app" {
for_each = local.enabled_apps
name = "${local.prefix}-${each.key}-vm"
computer_name = "${local.prefix}-${each.key}"
resource_group_name = azurerm_resource_group.lab.name
location = azurerm_resource_group.lab.location
size = each.value.vm_size
admin_username = var.admin_username
network_interface_ids = [azurerm_network_interface.app[each.key].id]
disable_password_authentication = true
admin_ssh_key {
username = var.admin_username
public_key = var.ssh_public_key
}
os_disk {
caching = "ReadWrite"
storage_account_type = "Standard_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts-gen2"
version = "latest"
}
tags = {
Application = each.key
}
}
output "enabled_app_names" {
value = keys(local.enabled_apps)
}
output "public_ips" {
value = { for name, pip in azurerm_public_ip.app : name => pip.ip_address }
}
Code language: JavaScript (javascript)
Run with the Azure workflow and SSH inputs. By default, only frontend and backend are created. Enabling sandbox creates its public IP, NIC, and VM.
8. Lab 5 — dynamic blocks
Concept
A dynamic block generates nested blocks within a resource. It is not the same as for_each on a resource: – for_each on a resource creates multiple top-level resources. – dynamic creates multiple nested blocks inside one resource.
8A. AWS — dynamic security group ingress rules
This example creates one security group and generates one ingress block per rule. SSH is restricted to your CIDR. HTTP is an example rule restricted to a private network range.
terraform {
required_version = ">= 1.3.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
variable "aws_region" {
type = string
default = "ap-northeast-1"
}
variable "ssh_cidr" {
type = string
}
variable "enable_http_rule" {
type = bool
default = true
}
locals {
fixed_rules = [
{
name = "ssh"
port = 22
cidr = var.ssh_cidr
}
]
optional_rules = var.enable_http_rule ? [
{
name = "http-private"
port = 80
cidr = "10.0.0.0/8"
}
] : []
ingress_rules = concat(local.fixed_rules, local.optional_rules)
}
resource "aws_security_group" "lab" {
name_prefix = "tf-dynamic-lab-"
description = "Dynamic ingress rule demonstration"
dynamic "ingress" {
for_each = local.ingress_rules
iterator = rule
content {
description = rule.value.name
from_port = rule.value.port
to_port = rule.value.port
protocol = "tcp"
cidr_blocks = [rule.value.cidr]
}
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "tf-dynamic-lab-sg"
}
}
output "configured_rules" {
value = local.ingress_rules
}
output "security_group_id" {
value = aws_security_group.lab.id
}
Code language: JavaScript (javascript)
Run:
terraform init
terraform fmt
terraform validate
terraform plan -var="ssh_cidr=YOUR_PUBLIC_IP/32"
terraform apply -var="ssh_cidr=YOUR_PUBLIC_IP/32"
terraform output
terraform plan -var="ssh_cidr=YOUR_PUBLIC_IP/32" -var="enable_http_rule=false"
terraform destroy -var="ssh_cidr=YOUR_PUBLIC_IP/32"
Code language: JavaScript (javascript)
This lab creates a security group only, so it does not create an EC2 instance. The HTTP rule is limited to 10.0.0.0/8 as an example and will not necessarily be reachable from your laptop.
8B. Azure — iterate NSG rules with for_each
Azure NSG rules are normally represented as separate azurerm_network_security_rule resources, so use for_each instead of a dynamic block for this provider pattern.
terraform {
required_version = ">= 1.3.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
provider "azurerm" {
features {}
resource_provider_registrations = "none"
}
variable "location" {
type = string
default = "Japan East"
}
variable "ssh_cidr" {
type = string
}
variable "enable_http_rule" {
type = bool
default = true
}
locals {
rules = merge(
{
ssh = {
priority = 100
port = "22"
source = var.ssh_cidr
}
},
var.enable_http_rule ? {
http = {
priority = 110
port = "80"
source = "10.0.0.0/8"
}
} : {}
)
}
resource "azurerm_resource_group" "lab" {
name = "tfdynamic-rg"
location = var.location
}
resource "azurerm_network_security_group" "lab" {
name = "tfdynamic-nsg"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
}
resource "azurerm_network_security_rule" "rule" {
for_each = local.rules
name = "Allow-${each.key}"
priority = each.value.priority
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = each.value.port
source_address_prefix = each.value.source
destination_address_prefix = "*"
resource_group_name = azurerm_resource_group.lab.name
network_security_group_name = azurerm_network_security_group.lab.name
}
output "created_rule_names" {
value = keys(azurerm_network_security_rule.rule)
}
Code language: JavaScript (javascript)
Run terraform init, terraform validate, and terraform plan -var="ssh_cidr=YOUR_PUBLIC_IP/32". Set -var="enable_http_rule=false" to omit the HTTP rule. Destroy the lab when finished. This lab focuses on iteration and NSG rules; the next labs demonstrate complete VM networking.
9. Lab 6 — Nested iteration and flatten
Concept
When input data is nested, a for expression can create a nested list and flatten() can turn it into one list. The flattened list can then be converted to a map for for_each.
Example input:
variable "applications" {
default = {
frontend = ["dev", "test"]
backend = ["dev", "prod"]
}
}
Code language: JavaScript (javascript)
Desired combinations: frontend-dev, frontend-test, backend-dev, backend-prod.
9A. AWS — one EC2 instance for each application/environment pair
terraform {
required_version = ">= 1.3.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
variable "aws_region" {
type = string
default = "ap-northeast-1"
}
variable "ami_id" {
type = string
}
variable "ssh_cidr" {
type = string
}
variable "applications" {
type = map(list(string))
default = {
frontend = ["dev", "test"]
backend = ["dev", "prod"]
}
}
locals {
pairs_nested = [
for app_name, environments in var.applications : [
for environment in environments : {
key = "${app_name}-${environment}"
app = app_name
environment = environment
}
]
]
pairs = flatten(local.pairs_nested)
instances = {
for item in local.pairs : item.key => item
}
}
resource "aws_security_group" "lab" {
name_prefix = "tf-flatten-lab-"
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [var.ssh_cidr]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_instance" "pair" {
for_each = local.instances
ami = var.ami_id
instance_type = "t3.micro"
vpc_security_group_ids = [aws_security_group.lab.id]
tags = {
Name = each.key
Application = each.value.app
Environment = each.value.environment
}
}
output "instance_keys" {
value = keys(aws_instance.pair)
}
output "instance_ids" {
value = { for key, instance in aws_instance.pair : key => instance.id }
}
Code language: PHP (php)
Run with the usual AWS commands and inputs. Expect four instances. The instances map provides stable, descriptive keys.
9B. Azure — one Linux VM for each application/environment pair
terraform {
required_version = ">= 1.3.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
provider "azurerm" {
features {}
resource_provider_registrations = "none"
}
variable "location" {
type = string
default = "Japan East"
}
variable "admin_username" {
type = string
default = "azureuser"
}
variable "ssh_public_key" {
type = string
}
variable "ssh_cidr" {
type = string
}
variable "applications" {
type = map(list(string))
default = {
frontend = ["dev", "test"]
backend = ["dev", "prod"]
}
}
locals {
prefix = "tfflatten"
pairs_nested = [
for app_name, environments in var.applications : [
for environment in environments : {
key = "${app_name}-${environment}"
app = app_name
environment = environment
}
]
]
pairs = flatten(local.pairs_nested)
instances = {
for item in local.pairs : item.key => item
}
}
resource "azurerm_resource_group" "lab" {
name = "${local.prefix}-rg"
location = var.location
}
resource "azurerm_virtual_network" "lab" {
name = "${local.prefix}-vnet"
address_space = ["10.140.0.0/16"]
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
}
resource "azurerm_subnet" "lab" {
name = "default"
resource_group_name = azurerm_resource_group.lab.name
virtual_network_name = azurerm_virtual_network.lab.name
address_prefixes = ["10.140.1.0/24"]
}
resource "azurerm_network_security_group" "lab" {
name = "${local.prefix}-nsg"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
security_rule {
name = "AllowSSHFromStudent"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = var.ssh_cidr
destination_address_prefix = "*"
}
}
resource "azurerm_public_ip" "pair" {
for_each = local.instances
name = "${local.prefix}-${each.key}-pip"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
allocation_method = "Static"
sku = "Standard"
}
resource "azurerm_network_interface" "pair" {
for_each = local.instances
name = "${local.prefix}-${each.key}-nic"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
ip_configuration {
name = "internal"
subnet_id = azurerm_subnet.lab.id
private_ip_address_allocation = "Dynamic"
public_ip_address_id = azurerm_public_ip.pair[each.key].id
}
}
resource "azurerm_network_interface_security_group_association" "pair" {
for_each = local.instances
network_interface_id = azurerm_network_interface.pair[each.key].id
network_security_group_id = azurerm_network_security_group.lab.id
}
resource "azurerm_linux_virtual_machine" "pair" {
for_each = local.instances
name = "${local.prefix}-${each.key}-vm"
computer_name = "${local.prefix}-${each.key}"
resource_group_name = azurerm_resource_group.lab.name
location = azurerm_resource_group.lab.location
size = "Standard_B1s"
admin_username = var.admin_username
network_interface_ids = [azurerm_network_interface.pair[each.key].id]
disable_password_authentication = true
admin_ssh_key {
username = var.admin_username
public_key = var.ssh_public_key
}
os_disk {
caching = "ReadWrite"
storage_account_type = "Standard_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts-gen2"
version = "latest"
}
tags = {
Application = each.value.app
Environment = each.value.environment
}
}
output "instance_keys" {
value = keys(azurerm_linux_virtual_machine.pair)
}
output "public_ips" {
value = { for key, pip in azurerm_public_ip.pair : key => pip.ip_address }
}
Code language: PHP (php)
Run with the Azure workflow and SSH inputs. Expect four VMs. If you change the nested lists, inspect the plan carefully: removed keys cause the corresponding VMs and network resources to be destroyed.
10. Lab 7 — setproduct for combinations
Concept
setproduct() returns combinations from two or more collections. It is useful for combinations such as each application across each environment. Only use it when all combinations are truly desired.
7A. AWS — create EC2 instances across app and environment combinations
terraform {
required_version = ">= 1.3.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
variable "aws_region" {
type = string
default = "ap-northeast-1"
}
variable "ami_id" {
type = string
}
variable "ssh_cidr" {
type = string
}
variable "applications" {
type = set(string)
default = ["frontend", "backend"]
}
variable "environments" {
type = set(string)
default = ["dev", "test"]
}
locals {
combinations = [
for pair in setproduct(var.applications, var.environments) : {
app = pair[0]
environment = pair[1]
key = "${pair[0]}-${pair[1]}"
}
]
instances = { for item in local.combinations : item.key => item }
}
resource "aws_security_group" "lab" {
name_prefix = "tf-product-lab-"
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [var.ssh_cidr]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_instance" "pair" {
for_each = local.instances
ami = var.ami_id
instance_type = "t3.micro"
vpc_security_group_ids = [aws_security_group.lab.id]
tags = {
Name = each.key
Application = each.value.app
Environment = each.value.environment
}
}
output "combination_keys" {
value = keys(aws_instance.pair)
}
Code language: PHP (php)
Run with AWS inputs. The defaults create four EC2 instances: frontend-dev, frontend-test, backend-dev, and backend-test.
7B. Azure — create Linux VMs across app and environment combinations
terraform {
required_version = ">= 1.3.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
provider "azurerm" {
features {}
resource_provider_registrations = "none"
}
variable "location" {
type = string
default = "Japan East"
}
variable "admin_username" {
type = string
default = "azureuser"
}
variable "ssh_public_key" {
type = string
}
variable "ssh_cidr" {
type = string
}
variable "applications" {
type = set(string)
default = ["frontend", "backend"]
}
variable "environments" {
type = set(string)
default = ["dev", "test"]
}
locals {
prefix = "tfproduct"
combinations = [
for pair in setproduct(var.applications, var.environments) : {
app = pair[0]
environment = pair[1]
key = "${pair[0]}-${pair[1]}"
}
]
instances = { for item in local.combinations : item.key => item }
}
resource "azurerm_resource_group" "lab" {
name = "${local.prefix}-rg"
location = var.location
}
resource "azurerm_virtual_network" "lab" {
name = "${local.prefix}-vnet"
address_space = ["10.150.0.0/16"]
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
}
resource "azurerm_subnet" "lab" {
name = "default"
resource_group_name = azurerm_resource_group.lab.name
virtual_network_name = azurerm_virtual_network.lab.name
address_prefixes = ["10.150.1.0/24"]
}
resource "azurerm_network_security_group" "lab" {
name = "${local.prefix}-nsg"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
security_rule {
name = "AllowSSHFromStudent"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = var.ssh_cidr
destination_address_prefix = "*"
}
}
resource "azurerm_public_ip" "pair" {
for_each = local.instances
name = "${local.prefix}-${each.key}-pip"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
allocation_method = "Static"
sku = "Standard"
}
resource "azurerm_network_interface" "pair" {
for_each = local.instances
name = "${local.prefix}-${each.key}-nic"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
ip_configuration {
name = "internal"
subnet_id = azurerm_subnet.lab.id
private_ip_address_allocation = "Dynamic"
public_ip_address_id = azurerm_public_ip.pair[each.key].id
}
}
resource "azurerm_network_interface_security_group_association" "pair" {
for_each = local.instances
network_interface_id = azurerm_network_interface.pair[each.key].id
network_security_group_id = azurerm_network_security_group.lab.id
}
resource "azurerm_linux_virtual_machine" "pair" {
for_each = local.instances
name = "${local.prefix}-${each.key}-vm"
computer_name = "${local.prefix}-${each.key}"
resource_group_name = azurerm_resource_group.lab.name
location = azurerm_resource_group.lab.location
size = "Standard_B1s"
admin_username = var.admin_username
network_interface_ids = [azurerm_network_interface.pair[each.key].id]
disable_password_authentication = true
admin_ssh_key {
username = var.admin_username
public_key = var.ssh_public_key
}
os_disk {
caching = "ReadWrite"
storage_account_type = "Standard_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts-gen2"
version = "latest"
}
tags = {
Application = each.value.app
Environment = each.value.environment
}
}
output "combination_keys" {
value = keys(azurerm_linux_virtual_machine.pair)
}
output "public_ips" {
value = { for key, pip in azurerm_public_ip.pair : key => pip.ip_address }
}
Code language: PHP (php)
Run with Azure inputs. Expect four VMs. Before scaling this pattern up, calculate the number of combinations: the total is the product of the collection sizes.
11. Lab 8 — Iterating over objects and maps
Concept
A map of objects lets each resource have its own settings. For example, each VM may have a name, size, and environment tag. each.value accesses that object’s attributes.
8A. AWS — per-instance configuration
terraform {
required_version = ">= 1.3.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
variable "aws_region" {
type = string
default = "ap-northeast-1"
}
variable "ami_id" {
type = string
}
variable "ssh_cidr" {
type = string
}
variable "servers" {
type = map(object({
instance_type = string
owner = string
environment = string
}))
default = {
api = {
instance_type = "t3.micro"
owner = "platform"
environment = "dev"
}
batch = {
instance_type = "t3.micro"
owner = "data"
environment = "test"
}
}
}
resource "aws_security_group" "lab" {
name_prefix = "tf-object-lab-"
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [var.ssh_cidr]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_instance" "server" {
for_each = var.servers
ami = var.ami_id
instance_type = each.value.instance_type
vpc_security_group_ids = [aws_security_group.lab.id]
tags = {
Name = each.key
Owner = each.value.owner
Environment = each.value.environment
}
}
output "server_configuration" {
value = {
for name, instance in aws_instance.server :
name => {
id = instance.id
public_ip = instance.public_ip
}
}
}
Code language: JavaScript (javascript)
Run with AWS inputs. Inspect the tags in the EC2 console and terraform output.
8B. Azure — per-VM configuration
terraform {
required_version = ">= 1.3.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
provider "azurerm" {
features {}
resource_provider_registrations = "none"
}
variable "location" {
type = string
default = "Japan East"
}
variable "admin_username" {
type = string
default = "azureuser"
}
variable "ssh_public_key" {
type = string
}
variable "ssh_cidr" {
type = string
}
variable "servers" {
type = map(object({
vm_size = string
owner = string
environment = string
}))
default = {
api = {
vm_size = "Standard_B1s"
owner = "platform"
environment = "dev"
}
batch = {
vm_size = "Standard_B1s"
owner = "data"
environment = "test"
}
}
}
locals {
prefix = "tfobject"
}
resource "azurerm_resource_group" "lab" {
name = "${local.prefix}-rg"
location = var.location
}
resource "azurerm_virtual_network" "lab" {
name = "${local.prefix}-vnet"
address_space = ["10.160.0.0/16"]
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
}
resource "azurerm_subnet" "lab" {
name = "default"
resource_group_name = azurerm_resource_group.lab.name
virtual_network_name = azurerm_virtual_network.lab.name
address_prefixes = ["10.160.1.0/24"]
}
resource "azurerm_network_security_group" "lab" {
name = "${local.prefix}-nsg"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
security_rule {
name = "AllowSSHFromStudent"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = var.ssh_cidr
destination_address_prefix = "*"
}
}
resource "azurerm_public_ip" "server" {
for_each = var.servers
name = "${local.prefix}-${each.key}-pip"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
allocation_method = "Static"
sku = "Standard"
}
resource "azurerm_network_interface" "server" {
for_each = var.servers
name = "${local.prefix}-${each.key}-nic"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
ip_configuration {
name = "internal"
subnet_id = azurerm_subnet.lab.id
private_ip_address_allocation = "Dynamic"
public_ip_address_id = azurerm_public_ip.server[each.key].id
}
}
resource "azurerm_network_interface_security_group_association" "server" {
for_each = var.servers
network_interface_id = azurerm_network_interface.server[each.key].id
network_security_group_id = azurerm_network_security_group.lab.id
}
resource "azurerm_linux_virtual_machine" "server" {
for_each = var.servers
name = "${local.prefix}-${each.key}-vm"
computer_name = "${local.prefix}-${each.key}"
resource_group_name = azurerm_resource_group.lab.name
location = azurerm_resource_group.lab.location
size = each.value.vm_size
admin_username = var.admin_username
network_interface_ids = [azurerm_network_interface.server[each.key].id]
disable_password_authentication = true
admin_ssh_key {
username = var.admin_username
public_key = var.ssh_public_key
}
os_disk {
caching = "ReadWrite"
storage_account_type = "Standard_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts-gen2"
version = "latest"
}
tags = {
Owner = each.value.owner
Environment = each.value.environment
}
}
output "server_configuration" {
value = {
for name, vm in azurerm_linux_virtual_machine.server :
name => {
id = vm.id
public_ip = azurerm_public_ip.server[name].ip_address
}
}
}
Code language: JavaScript (javascript)
Run with Azure SSH inputs. Each object drives the size and tags for the corresponding VM.
12. Lab 9 — Conditional iteration
Concept
Iteration and conditional logic can be combined. For example, use a map of services and include only those with enabled = true. This is useful for optional environments, apps, and infrastructure components.
9A. AWS — enabled instances and per-app types
Use the AWS filtering example from Lab 4 as the complete lab. Its key logic is:
locals {
enabled_apps = {
for name, app in var.applications : name => app
if app.enabled
}
}
resource "aws_instance" "app" {
for_each = local.enabled_apps
ami = var.ami_id
instance_type = each.value.instance_type
# Include the security group and tags from Lab 4.
}
Code language: PHP (php)
The full standalone AWS configuration is in Lab 4A. Copy that entire example into its own directory to run it.
9B. Azure — enabled VMs and related network resources
Use the Azure filtering example from Lab 4 as the complete lab. Its key logic is:
locals {
enabled_apps = {
for name, app in var.applications : name => app
if app.enabled
}
}
resource "azurerm_linux_virtual_machine" "app" {
for_each = local.enabled_apps
# The full VM, NIC, public IP, and NSG configuration is in Lab 4B.
}
Code language: PHP (php)
The full standalone Azure configuration is in Lab 4B. Copy that entire example into its own directory. The public IPs, NICs, associations, and VMs all use the same filtered map, so their keys stay aligned.
13. Lab 10 — Iterating over modules
Concept
Terraform modules can use count or for_each, just like resources. This lab uses a local module so you can experiment without cloud costs. The same pattern can be applied to a module that provisions EC2 or Azure VMs.
Create this directory structure:
module-iteration-lab/
├── main.tf
└── modules/
└── label/
└── main.tf
Code language: JavaScript (javascript)
Root main.tf:
terraform {
required_version = ">= 1.3.0"
}
variable "labels" {
type = set(string)
default = ["frontend", "backend", "worker"]
}
module "label" {
source = "./modules/label"
for_each = var.labels
label = each.key
}
output "uppercase_labels" {
value = { for name, module_instance in module.label : name => module_instance.value }
}
Code language: PHP (php)
modules/label/main.tf:
variable "label" {
type = string
}
output "value" {
value = upper(var.label)
}
Code language: JavaScript (javascript)
Run:
terraform init
terraform fmt
terraform validate
terraform plan
terraform apply
terraform output
terraform destroy
Expected output is a map like:
{
"backend" = "BACKEND"
"frontend" = "FRONTEND"
"worker" = "WORKER"
}
Code language: JavaScript (javascript)
No cloud resources are created in this lab.
14. count vs for_each
Question count for_each
Input type Integer Map or set of strings
Resource address [0], [1], etc. ["key"]
Best use Numeric quantity or Named, independently on/off managed resources
Stable identity Index-based Key-based
Typical risk Index changes can shift Changing a key changes identities the address
Example addresses
# count
aws_instance.web[0]
# for_each
aws_instance.app["frontend"]
Code language: CSS (css)
Prefer for_each when resources have meaningful names or different configuration. Prefer count for a simple number of interchangeable resources or a basic conditional resource.
15. Common errors and troubleshooting
Invalid for_each argument
Cause: The value is not a map or set of strings, or Terraform cannot know the keys during planning.
Fix: Use a map/set and keys known before apply. A common pattern is to use known application names as keys and put computed IDs in values.
Invalid count argument
Cause: Terraform cannot determine the number of instances during planning.
Fix: Base count on known variables or configuration. Avoid using a resource attribute that is unknown until apply.
Invalid index when count is zero
Cause: Code references resource.example[0] even though count = 0.
Fix: Guard the reference with a conditional expression or use a splat expression such as resource.example[*].id.
Duplicate keys in a for expression
Cause: A map comprehension produces the same key more than once.
Fix: Make keys unique or use grouping mode (...) if grouping values under each key is intended.
Unexpected replacement when changing count
Cause: Counted resources are indexed numerically. Removing or reordering list elements can change which object belongs to an index.
Fix: Use for_each with stable names for independently meaningful resources.
Azure VM size or quota error
Cause: The size is not available in the region, restricted by policy, or exceeds subscription quota.
Fix: Verify regional availability, subscription quota, and policy. Terraform iteration cannot increase quota.
AWS AMI not found
Cause: AMI IDs are regional or stale.
Fix: Find a current Ubuntu AMI in the provider’s configured region.
SSH does not work
Check the source CIDR, public IP, routing, NSG/security group, and private key. Supply the SSH public key to Azure. Do not expose SSH globally to solve a connectivity issue.
16. Best practices
- Use
for_eachfor named infrastructure that should retain stable identities. - Use
countfor simple quantities and on/off resources. - Keep
forexpressions readable; use intermediatelocalsfor complex transformations. - Filter a collection before passing it to
for_each. - Use the same key map for related resources such as public IPs, NICs, and VMs.
- Use
dynamiconly for nested blocks; it is not a replacement for resourcefor_each. - Check the plan after changing keys or counts because instances may be destroyed or replaced.
- Keep resource names unique and within cloud-provider naming restrictions.
- Pin provider major versions and review upgrades.
- Use least-privilege networking and keep secrets out of Terraform files.
- Keep each lab in its own directory or isolated state.
- Destroy lab infrastructure when finished.
17. Quick reference
Task Syntax
Repeat a resource N times count = var.n Access count index count.index Collect count outputs resource.example[*].id Repeat for each map/set item for_each = var.items Access current key/value each.key, each.value Transform a list [for x in var.items : expression] Build a map { for x in var.items : x.key => x.value } Filter a collection [for x in var.items : x if condition] Filter a map { for k, v in var.map : k => v if condition } Flatten nested lists flatten(local.nested) Convert list to set toset(var.list) Extract map keys keys(var.map) Extract map values values(var.map) Generate combinations setproduct(var.a, var.b) Generate nested blocks dynamic "block_name" { for_each = ... } Repeat module instances module "x" { for_each = ... }
18. Practice exercises
Exercise 1 — Count
Create two EC2 instances or Azure Linux VMs using count. Output all IDs.
Hint: count = 2; use a splat expression in the output.
Exercise 2 — for_each
Create resources for frontend, backend, and worker, each with a different tag.
Hint: Use a map and each.key / each.value.
Exercise 3 — Filtering
Given an object map containing enabled = true/false, create only enabled resources.
Hint:
locals {
enabled_items = {
for name, item in var.items : name => item
if item.enabled
}
}
Code language: JavaScript (javascript)
Exercise 4 — Transform a list
Convert ["dev", "test", "prod"] to uppercase.
Answer:
[for environment in var.environments : upper(environment)]
Code language: JSON / JSON with Comments (json)
Exercise 5 — Flatten
Given a map of applications to environments, create one resource per app/environment pair.
Hint: Build a nested list with nested for expressions, call flatten(), and then convert the result to a map keyed by a unique string.
Exercise 6 — Dynamic blocks
Generate multiple AWS security group ingress rules from a list of objects.
Hint: Define a list of rule objects and use dynamic "ingress" with for_each.
Exercise 7 — Module iteration
Call a local module once per item in a set of strings.
Hint: module "example" { for_each = var.items ... }.
Final checklist
- [ ] I can explain the difference between
countandfor_each. - [ ] I can use
forexpressions to transform a collection. - [ ] I can filter a map or list with
if. - [ ] I understand when
dynamicblocks are appropriate. - [ ] I can flatten nested lists and generate combinations.
- [ ] I can read resource addresses in a Terraform plan.
- [ ] I checked all plans before applying.
- [ ] I destroyed all temporary cloud resources.
Key idea: count and for_each repeat resource or module instances; for expressions transform collections; dynamic blocks repeat nested configuration. Choose the construct that matches the job.
I’m Rajesh Kumar, a DevOps, SRE, DevSecOps, Cloud, and Platform Engineering expert passionate about sharing practical knowledge, real-world experiences, and industry best practices. I have worked at Cotocus and regularly write about technology, travel, investing, health, product reviews, and digital marketing through my various platforms.
I publish technical articles at DevOps School, travel stories at Holiday Landmark, stock market insights at Stocks Mantra, health and fitness guidance at My Medic Plus, product reviews at TrueReviewNow, and SEO and digital marketing strategies at Wizbrand.
Find Trusted Cardiac Hospitals
Compare heart hospitals by city and services — all in one place.
Explore Hospitals