AWS OpenTofu #204: for_each, count, and Dynamic Infrastructure
Eliminate list index shift bugs with for_each maps, generate flexible nested configuration using dynamic blocks, and master conditional resource provisioning.
AWS + OpenTofu Level 200: Build a Reusable AWS Application Platform
Part of the CLI-first comprehensive learning path from foundational setup to production cloud architectures.
In Lab 203, you learned how to compose modular architectures by passing outputs into inputs across networking, security, and compute layers.
As your infrastructure expands from static single-instance configurations to multi-AZ, multi-subnet, and multi-tier enterprise platforms, you will quickly encounter a repetitive coding problem:
How do you create 6 subnets across 3 Availability Zones without copy-pasting the aws_subnet block 6 times? How do you define a Security Group whose ingress rules vary depending on whether it is deployed to development, staging, or production? And how do you conditionally enable or disable an expensive NAT Gateway with a simple boolean flag?
In OpenTofu, two meta-arguments make configuration dynamic: count and for_each.
However, using them incorrectly is one of the most common causes of production outages in cloud infrastructure.
In this lab, you will uncover the infamous “Index Shift” disaster caused by count on lists, master predictable resource addressing using for_each maps, configure nested configuration using dynamic blocks, and learn the exact architectural rules for when to use each pattern.
🎯 Learning Objectives
| Dynamic Meta-Arguments | Advanced HCL Patterns | Operational Rigor |
|---|---|---|
• The mechanics of for_each vs count | • Constructing map(object({...})) schemas | • Safe schema expansion without recreation |
| • Eliminating List Index Shift disasters | • Dynamic nested blocks (dynamic "ingress") | • Predictable addressing in state inspection |
• Clean boolean resource toggles with count | • Filtering maps with for expressions | • Cost-conscious dynamic tier teardown |
💰 Estimated Lab Cost
| Resource | Configuration | Free Tier Cost | Standard Account (Past Free Tier) |
|---|---|---|---|
| Custom Multi-Tier VPC | 1 VPC (10.20.0.0/16) | $0.00 | $0.00 (Always 100% Free) |
| Dynamic Subnets | 4 Subnets generated via for_each | $0.00 | $0.00 (Always 100% Free) |
| Route Tables & IGW | 1 Internet Gateway, 2 Route Tables | $0.00 | $0.00 (Always 100% Free) |
| Dynamic Security Group | 1 SG with dynamic ingress rules | $0.00 | $0.00 (Always 100% Free) |
| Optional Bastion EC2 | 1x t3.micro On-Demand (Toggled via count) | $0.00 | ~$0.012 / hour |
| Total Estimated Cost | Complete Hands-On Session (~1 hour) | $0.00 | <$0.015 (Under 2 cents) |
[!NOTE] All core networking resources in this lab (VPC, Subnets, Route Tables, Security Groups) cost $0.00 on AWS. The only potentially billable item is the optional EC2 instance, which is covered under the AWS Free Tier (or costs ~$0.012/hour on standard accounts). We will destroy all resources at the end of the lab.
💥 The Anatomy of a Disaster: The count Index Shift
To understand why for_each exists, you must first experience the architectural flaw of using count to manage collections of resources.
The Naive count Approach
Suppose you need to provision three public subnets across three Availability Zones. A developer might write:
variable "subnet_cidrs" {
type = list(string)
default = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
}
resource "aws_subnet" "public" {
count = length(var.subnet_cidrs)
vpc_id = aws_vpc.main.id
cidr_block = var.subnet_cidrs[count.index]
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = {
Name = "public-subnet-${count.index}"
}
}
OpenTofu records these three subnets in the state file using numeric index keys:
aws_subnet.public[0] --> 10.0.1.0/24 (AZ: us-east-1a)
aws_subnet.public[1] --> 10.0.2.0/24 (AZ: us-east-1b)
aws_subnet.public[2] --> 10.0.3.0/24 (AZ: us-east-1c)
Now, imagine production has been running on this infrastructure for six months. Applications, load balancers, and database read-replicas are active in these subnets.
The Catastrophic Pull Request
A developer is asked to remove the first subnet (10.0.1.0/24) because AZ us-east-1a is being phased out for maintenance:
variable "subnet_cidrs" {
type = list(string)
- default = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
+ default = ["10.0.2.0/24", "10.0.3.0/24"]
}
The developer runs tofu plan. What do you expect OpenTofu to do?
You might expect it to simply destroy the old 10.0.1.0/24 subnet. But OpenTofu does not know what CIDR corresponds to which index. It only knows indices:
Before Change:
Index 0 = 10.0.1.0/24
Index 1 = 10.0.2.0/24
Index 2 = 10.0.3.0/24
After Change:
Index 0 = 10.0.2.0/24 <-- OpenTofu sees: "Modify or Replace aws_subnet.public[0]"!
Index 1 = 10.0.3.0/24 <-- OpenTofu sees: "Modify or Replace aws_subnet.public[1]"!
Index 2 = (Missing) <-- OpenTofu sees: "Destroy aws_subnet.public[2]"!
# OpenTofu Execution Plan Diff:
-/+ aws_subnet.public[0] must be REPLACED (forces replacement: cidr_block 10.0.1.0/24 -> 10.0.2.0/24)
-/+ aws_subnet.public[1] must be REPLACED (forces replacement: cidr_block 10.0.2.0/24 -> 10.0.3.0/24)
- aws_subnet.public[2] will be DESTROYED
Plan: 2 to add, 0 to change, 3 to destroy.
Every single subnet in the environment will be destroyed and recreated.
If production databases or EC2 instances are attached to those subnets, AWS will block the deletion with DependencyViolation, crashing the deployment pipeline midway and leaving the state in an inconsistent, broken state.
This is the List Index Shift anti-pattern.
🛡️ The for_each Solution: Immutable Semantic Keys
for_each solves this problem permanently by identifying resources using meaningful, immutable string keys instead of fragile numeric array indices.
variable "subnets" {
type = map(object({
cidr_block = string
az = string
}))
default = {
"public-a" = { cidr_block = "10.0.1.0/24", az = "us-east-1a" }
"public-b" = { cidr_block = "10.0.2.0/24", az = "us-east-1b" }
"public-c" = { cidr_block = "10.0.3.0/24", az = "us-east-1c" }
}
}
resource "aws_subnet" "public" {
for_each = var.subnets
vpc_id = aws_vpc.main.id
cidr_block = each.value.cidr_block
availability_zone = each.value.az
tags = {
Name = each.key
}
}
In the state file, OpenTofu addresses these resources as:
aws_subnet.public["public-a"]
aws_subnet.public["public-b"]
aws_subnet.public["public-c"]
If you delete "public-a" from the map, OpenTofu performs a targeted, surgical operation:
- aws_subnet.public["public-a"] will be DESTROYED
Plan: 0 to add, 0 to change, 1 to destroy.
aws_subnet.public["public-b"] and aws_subnet.public["public-c"] are completely untouched. No index shifts, no cascading replacements, zero downtime.
⚖️ When to Use count vs for_each
Does this mean count is deprecated? No. Both meta-arguments have distinct, valid purposes in enterprise IaC:
flowchart TD
Start["Do you need multiple instances or conditional creation?"] --> Question{"What is the intent?"}
Question -->|Multiple Distinct Resources| Multi{"How are items identified?"}
Multi -->|By semantic key, name, or AZ| UseForEach["Use for_each (Maps or Sets)"]
Multi -->|Identical, truly interchangeable copies| CheckCount["Count is acceptable (Rare)"]
Question -->|Conditional On/Off Toggle| Bool{"Is it a 0 or 1 toggle?"}
Bool -->|count = var.enabled ? 1 : 0| UseCount["Use count (Standard Idiom)"]
Bool -->|for_each = var.enabled ? { 'this' = {} } : {}| AltForEach["for_each map toggle (Alternative)"]
The Golden Rules
- Use
for_eachwhenever you are iterating over a collection of resources that have individual identity (subnets, IAM users, security group rules, disk volumes, S3 buckets). - Use
countexclusively for binary conditional toggles where a resource is either provisioned (count = 1) or skipped (count = 0). - Never use
countwithlength(list)if elements in that list could ever be reordered, inserted at the beginning, or removed from the middle.
🧩 Dynamic Blocks: Flexible Nested HCL Configuration
Top-level resources can be multiplied using for_each. But what about nested configuration blocks inside a resource?
Consider an AWS Security Group. Inside the aws_security_group resource, you define repeated ingress blocks:
resource "aws_security_group" "web" {
name = "web-sg"
vpc_id = aws_vpc.main.id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 8080
to_port = 8080
protocol = "tcp"
cidr_blocks = ["10.0.0.0/16"]
}
}
If you hardcode these blocks, your Security Group module is rigid. If a customer needs port 8443 opened or port 8080 closed, they have to modify the module’s code directly.
Enter dynamic blocks.
How dynamic Blocks Work
A dynamic block acts like a for loop that produces nested configuration blocks inside a resource:
variable "ingress_rules" {
type = map(object({
port = number
protocol = string
cidr_blocks = list(string)
description = string
}))
default = {
"http" = {
port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
description = "Allow inbound plain HTTP"
}
"https" = {
port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
description = "Allow inbound TLS HTTPS"
}
}
}
resource "aws_security_group" "dynamic_web" {
name = "dynamic-web-sg"
description = "Security group with dynamically generated ingress rules"
vpc_id = aws_vpc.main.id
dynamic "ingress" {
for_each = var.ingress_rules
content {
description = ingress.value.description
from_port = ingress.value.port
to_port = ingress.value.port
protocol = ingress.value.protocol
cidr_blocks = ingress.value.cidr_blocks
}
}
egress {
description = "Allow all outbound traffic"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
Key Mechanics of dynamic Blocks
dynamic "ingress": Tells OpenTofu to generate nested blocks of typeingress.for_each: Accepts any map or set of objects.content: The block template. Insidecontent, OpenTofu provides an iterator object with the same name as the block (ingress.keyandingress.value).- Custom Iterators: If you want to rename the iterator variable to avoid ambiguity, use the
iteratorargument:dynamic "ingress" { for_each = var.ingress_rules iterator = rule content { from_port = rule.value.port to_port = rule.value.port # ... } }
[!WARNING] FinOps & Clean Code Guardrail: Do not abuse dynamic blocks! Turning entire resources into deeply nested dynamic blocks with complex ternary logic makes code unreadable and impossible to audit. Dynamic blocks should only be used when the number of nested blocks must be controlled dynamically by callers via input variables.
🛠️ Hands-On Lab: Build Dynamic Multi-Tier Infrastructure
Let’s put theory into practice. You will build a complete dynamic infrastructure project that demonstrates:
- Multi-tier subnets (Public and Private) generated from a single map of objects using
for_each. - Dynamic Route Table associations using
for_eachand HCL map filtering (forexpressions). - A Security Group with dynamic
ingressblocks driven by variable rules. - A conditional Bastion host provisioned using
count = var.enable_bastion ? 1 : 0. - A live schema mutation test proving that adding or deleting subnets produces zero index shift drift.
Step 1: Create the Lab Workspace
Create a dedicated workspace directory:
mkdir -p ~/opentofu-labs/lab-204-dynamic-infrastructure
cd ~/opentofu-labs/lab-204-dynamic-infrastructure
Verify your AWS identity:
aws sts get-caller-identity
Step 2: Configure Providers and Version Constraints
Create providers.tf:
# providers.tf
terraform {
required_version = ">= 1.8.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.50"
}
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project = "OpenTofu-Level-200"
Lab = "Lab-204"
Environment = var.environment
ManagedBy = "OpenTofu"
}
}
}
Step 3: Define Variables with Rich Object Schemas
Create variables.tf. Notice the type definition for subnets: a map(object({ ... })) schema that enforces type safety at compile time:
# variables.tf
variable "aws_region" {
type = string
description = "AWS region for infrastructure deployment"
default = "us-east-1"
}
variable "environment" {
type = string
description = "Environment identifier"
default = "dev"
}
variable "vpc_cidr" {
type = string
description = "CIDR block for the VPC"
default = "10.20.0.0/16"
}
variable "subnets" {
type = map(object({
cidr_block = string
az_suffix = string
tier = string
map_public_ip_on_launch = bool
}))
description = "Map of subnets to create with tier classifications"
default = {
"public-1a" = {
cidr_block = "10.20.1.0/24"
az_suffix = "a"
tier = "public"
map_public_ip_on_launch = true
}
"public-1b" = {
cidr_block = "10.20.2.0/24"
az_suffix = "b"
tier = "public"
map_public_ip_on_launch = true
}
"private-1a" = {
cidr_block = "10.20.10.0/24"
az_suffix = "a"
tier = "private"
map_public_ip_on_launch = false
}
"private-1b" = {
cidr_block = "10.20.20.0/24"
az_suffix = "b"
tier = "private"
map_public_ip_on_launch = false
}
}
}
variable "firewall_ingress_rules" {
type = map(object({
port = number
protocol = string
cidr_blocks = list(string)
description = string
}))
description = "Dynamic ingress firewall rules for the application security group"
default = {
"http" = {
port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
description = "Public HTTP web traffic"
}
"https" = {
port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
description = "Public HTTPS secure web traffic"
}
"api_alt" = {
port = 8080
protocol = "tcp"
cidr_blocks = ["10.20.0.0/16"]
description = "Internal microservice API port"
}
}
}
variable "enable_bastion" {
type = bool
description = "Whether to provision an administrative bastion EC2 instance"
default = true
}
Step 4: Build the Dynamic Networking Layer
Create network.tf.
Pay close attention to how we use for_each with a for expression filter to associate only public subnets with the public route table:
# network.tf
# 1. Base VPC
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${var.environment}-dynamic-vpc"
}
}
# 2. Internet Gateway for Public Traffic
resource "aws_internet_gateway" "gw" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.environment}-igw"
}
}
# 3. Dynamic Subnet Provisioning via for_each
resource "aws_subnet" "tiers" {
for_each = var.subnets
vpc_id = aws_vpc.main.id
cidr_block = each.value.cidr_block
availability_zone = "${var.aws_region}${each.value.az_suffix}"
map_public_ip_on_launch = each.value.map_public_ip_on_launch
tags = {
Name = "${var.environment}-${each.key}"
Tier = each.value.tier
}
}
# 4. Public Route Table
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.gw.id
}
tags = {
Name = "${var.environment}-public-rt"
Tier = "public"
}
}
# 5. Dynamic Route Table Association for Public Subnets Only
# Notice the HCL 'for' expression filtering only subnets where tier == 'public'
resource "aws_route_table_association" "public" {
for_each = {
for k, v in var.subnets : k => v
if v.tier == "public"
}
subnet_id = aws_subnet.tiers[each.key].id
route_table_id = aws_route_table.public.id
}
[!TIP] The
for ... ifFilter Pattern:for_each = { for k, v in var.subnets : k => v if v.tier == "public" }This powerful expression iterates over the input map, evaluates the condition (
if v.tier == "public"), and generates a subset map containing only matching items. The resulting resource addresses are completely predictable:aws_route_table_association.public["public-1a"]aws_route_table_association.public["public-1b"]
Step 5: Implement Dynamic Security Groups
Create security.tf. This implements the dynamic "ingress" block:
# security.tf
resource "aws_security_group" "app_firewall" {
name = "${var.environment}-app-firewall"
description = "Application firewall with dynamic ingress rules"
vpc_id = aws_vpc.main.id
# Dynamic nested block generation
dynamic "ingress" {
for_each = var.firewall_ingress_rules
content {
description = ingress.value.description
from_port = ingress.value.port
to_port = ingress.value.port
protocol = ingress.value.protocol
cidr_blocks = ingress.value.cidr_blocks
}
}
egress {
description = "Allow all outbound traffic"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.environment}-app-firewall"
}
}
Step 6: Conditional Workload Provisioning with count
Create compute.tf. Here, we use count = var.enable_bastion ? 1 : 0 to create an administrative bastion host only when enabled:
# compute.tf
# 1. Discover latest Amazon Linux 2023 AMI
data "aws_ami" "amazon_linux_2023" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-2023.*-kernel-6.1-x86_64"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
# 2. IAM Role for Systems Manager (SSM) Access (Zero Open Inbound Ports)
resource "aws_iam_role" "bastion_ssm" {
count = var.enable_bastion ? 1 : 0
name = "${var.environment}-bastion-ssm-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ec2.amazonaws.com"
}
}
]
})
}
resource "aws_iam_role_policy_attachment" "ssm_core" {
count = var.enable_bastion ? 1 : 0
role = aws_iam_role.bastion_ssm[0].name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
resource "aws_iam_instance_profile" "bastion" {
count = var.enable_bastion ? 1 : 0
name = "${var.environment}-bastion-profile"
role = aws_iam_role.bastion_ssm[0].name
}
# 3. Optional Bastion Host
resource "aws_instance" "bastion" {
count = var.enable_bastion ? 1 : 0
ami = data.aws_ami.amazon_linux_2023.id
instance_type = "t3.micro"
subnet_id = aws_subnet.tiers["public-1a"].id
iam_instance_profile = aws_iam_instance_profile.bastion[0].name
vpc_security_group_ids = [
aws_security_group.app_firewall.id
]
root_block_device {
volume_size = 20
volume_type = "gp3"
delete_on_termination = true
encrypted = true
}
tags = {
Name = "${var.environment}-bastion-host"
}
}
Step 7: Define Dynamic Outputs with Splat & Expressions
Create outputs.tf. Observe how we handle conditional outputs with try() and one():
# outputs.tf
output "vpc_id" {
description = "The ID of the VPC"
value = aws_vpc.main.id
}
output "subnet_ids_by_tier" {
description = "Map of all subnet IDs grouped by tier"
value = {
public = {
for k, s in aws_subnet.tiers : k => s.id
if s.tags["Tier"] == "public"
}
private = {
for k, s in aws_subnet.tiers : k => s.id
if s.tags["Tier"] == "private"
}
}
}
output "security_group_ingress_rules_count" {
description = "Number of dynamic ingress rules active in firewall"
value = length(var.firewall_ingress_rules)
}
output "bastion_instance_id" {
description = "ID of the bastion instance if enabled, null otherwise"
value = one(aws_instance.bastion[*].id)
}
output "bastion_public_ip" {
description = "Public IP address of the bastion host"
value = try(aws_instance.bastion[0].public_ip, "No Bastion Configured")
}
Step 8: Initialize and Apply the Architecture
Run tofu init to download the AWS provider:
tofu init
Generate an execution plan:
tofu plan -out=tfplan
Review the plan output. Note how every resource is addressed by its key:
aws_subnet.tiers["public-1a"]aws_subnet.tiers["public-1b"]aws_subnet.tiers["private-1a"]aws_subnet.tiers["private-1b"]aws_route_table_association.public["public-1a"]aws_route_table_association.public["public-1b"]aws_instance.bastion[0]
Apply the plan:
tofu apply tfplan
After roughly 30–60 seconds, OpenTofu outputs your infrastructure:
Apply complete! Resources: 12 added, 0 changed, 0 destroyed.
Outputs:
bastion_instance_id = "i-0a1b2c3d4e5f67890"
bastion_public_ip = "54.198.24.120"
security_group_ingress_rules_count = 3
subnet_ids_by_tier = {
"private" = {
"private-1a" = "subnet-01111111111111111"
"private-1b" = "subnet-02222222222222222"
}
"public" = {
"public-1a" = "subnet-03333333333333333"
"public-1b" = "subnet-04444444444444444"
}
}
vpc_id = "vpc-0abc123def456789a"
Step 9: The Mutation Test — Proving Zero Index Shift Drift
Now, let’s execute the exact test that destroyed the hypothetical company’s infrastructure in our earlier example.
We will add a third public subnet and a database private subnet to variables.tf.
Edit variables.tf and update the subnets variable:
variable "subnets" {
type = map(object({
cidr_block = string
az_suffix = string
tier = string
map_public_ip_on_launch = bool
}))
description = "Map of subnets to create with tier classifications"
default = {
"public-1a" = {
cidr_block = "10.20.1.0/24"
az_suffix = "a"
tier = "public"
map_public_ip_on_launch = true
}
"public-1b" = {
cidr_block = "10.20.2.0/24"
az_suffix = "b"
tier = "public"
map_public_ip_on_launch = true
}
# NEW: Third public subnet in AZ 'c'
"public-1c" = {
cidr_block = "10.20.3.0/24"
az_suffix = "c"
tier = "public"
map_public_ip_on_launch = true
}
"private-1a" = {
cidr_block = "10.20.10.0/24"
az_suffix = "a"
tier = "private"
map_public_ip_on_launch = false
}
"private-1b" = {
cidr_block = "10.20.20.0/24"
az_suffix = "b"
tier = "private"
map_public_ip_on_launch = false
}
# NEW: Dedicated Database Subnet in AZ 'a'
"db-1a" = {
cidr_block = "10.20.100.0/24"
az_suffix = "a"
tier = "database"
map_public_ip_on_launch = false
}
}
}
Now run tofu plan:
tofu plan
Examine the plan output carefully:
OpenTofu will perform the following actions:
# aws_route_table_association.public["public-1c"] will be created
+ resource "aws_route_table_association" "public" {
+ id = (known after apply)
+ route_table_id = "rtb-0123456789abcdef0"
+ subnet_id = (known after apply)
}
# aws_subnet.tiers["db-1a"] will be created
+ resource "aws_subnet" "tiers" {
+ cidr_block = "10.20.100.0/24"
+ availability_zone = "us-east-1a"
+ tags = {
+ "Name" = "dev-db-1a"
+ "Tier" = "database"
}
# ...
}
# aws_subnet.tiers["public-1c"] will be created
+ resource "aws_subnet" "tiers" {
+ cidr_block = "10.20.3.0/24"
+ availability_zone = "us-east-1c"
+ tags = {
+ "Name" = "dev-public-1c"
+ "Tier" = "public"
}
# ...
}
Plan: 3 to add, 0 to change, 0 to destroy.
Zero resources are modified. Zero resources are destroyed.
The existing public-1a, public-1b, private-1a, and private-1b subnets remain completely online.
Apply the expansion:
tofu apply -auto-approve
Step 10: Mutating Dynamic Blocks on the Fly
Let’s test dynamic blocks by adding a database port rule to the security group without touching security.tf.
Update firewall_ingress_rules in variables.tf:
variable "firewall_ingress_rules" {
type = map(object({
port = number
protocol = string
cidr_blocks = list(string)
description = string
}))
description = "Dynamic ingress firewall rules for the application security group"
default = {
"http" = {
port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
description = "Public HTTP web traffic"
}
"https" = {
port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
description = "Public HTTPS secure web traffic"
}
"api_alt" = {
port = 8080
protocol = "tcp"
cidr_blocks = ["10.20.0.0/16"]
description = "Internal microservice API port"
}
# NEW: PostgreSQL port for private communication
"postgres" = {
port = 5432
protocol = "tcp"
cidr_blocks = ["10.20.0.0/16"]
description = "Internal PostgreSQL ingress from VPC"
}
}
}
Run tofu plan:
tofu plan
OpenTofu recognizes that security.tf has not changed, but the variable input has. It generates an in-place update for the security group:
~ resource "aws_security_group" "app_firewall" {
~ ingress = [
# (3 unchanged elements hidden)
+ {
+ cidr_blocks = [
+ "10.20.0.0/16",
]
+ description = "Internal PostgreSQL ingress from VPC"
+ from_port = 5432
+ protocol = "tcp"
+ to_port = 5432
# ...
},
]
# (all other attributes unchanged)
}
Plan: 0 to add, 1 to change, 0 to destroy.
Apply the change:
tofu apply -auto-approve
The firewall now accepts PostgreSQL traffic from the VPC CIDR with zero downtime.
Step 11: Testing the Boolean Feature Toggle
Finally, test the boolean toggle pattern. Suppose you want to disable the bastion host to save costs in non-production environments.
Override the variable at the command line:
tofu plan -var="enable_bastion=false"
OpenTofu immediately detects that count evaluates to 0:
- aws_iam_instance_profile.bastion[0] will be DESTROYED
- aws_iam_role.bastion_ssm[0] will be DESTROYED
- aws_iam_role_policy_attachment.ssm_core[0] will be DESTROYED
- aws_instance.bastion[0] will be DESTROYED
Plan: 0 to add, 0 to change, 4 to destroy.
Notice how outputs.tf behaves when the bastion is destroyed:
bastion_instance_idevaluates cleanly tonullviaone().bastion_public_ipfalls back to"No Bastion Configured"viatry().
🔍 Verification & Deep State Inspection
Inspect how OpenTofu tracks for_each resources in its state:
tofu state list
Sample output:
data.aws_ami.amazon_linux_2023
aws_iam_instance_profile.bastion[0]
aws_iam_role.bastion_ssm[0]
aws_iam_role_policy_attachment.ssm_core[0]
aws_instance.bastion[0]
aws_internet_gateway.gw
aws_route_table.public
aws_route_table_association.public["public-1a"]
aws_route_table_association.public["public-1b"]
aws_route_table_association.public["public-1c"]
aws_security_group.app_firewall
aws_subnet.tiers["db-1a"]
aws_subnet.tiers["private-1a"]
aws_subnet.tiers["private-1b"]
aws_subnet.tiers["public-1a"]
aws_subnet.tiers["public-1b"]
aws_subnet.tiers["public-1c"]
aws_vpc.main
Notice the semantic clarity of the keys. Even if you have 50 subnets, you can inspect any specific subnet’s details immediately:
tofu state show 'aws_subnet.tiers["public-1c"]'
⚠️ Common Gotchas & Troubleshooting
1. The toset() Requirement for Lists in for_each
Symptom:
Error: Invalid for_each argument
The given "for_each" argument value is unsuitable: "for_each" supports maps and sets of strings, but you have provided a list of strings.
Cause:
for_each requires a map or a set. It cannot accept a raw list(string) because list elements may contain duplicates or lack an immutable key.
Fix:
Wrap the list in toset():
variable "az_names" {
type = list(string)
default = ["us-east-1a", "us-east-1b"]
}
resource "aws_subnet" "example" {
for_each = toset(var.az_names)
cidr_block = "10.0.1.0/24"
availability_zone = each.key
}
2. Referencing Unknown Values in for_each Keys
Symptom:
Error: Invalid for_each argument
The "for_each" value depends on resource attributes that cannot be determined until apply.
Cause:
OpenTofu builds the dependency DAG during the planning phase. The keys of a for_each map must be known before apply. You cannot use computed attributes (such as an EC2 instance ID or an auto-generated token) as the key of a for_each map.
Fix:
Use static or input-variable string keys (e.g., "primary", "secondary", "us-east-1a"). You can compute the values inside the map, but the keys must be known at plan time.
3. Missing try() or one() on Conditional Outputs
Symptom:
Error: Invalid index
var.enable_bastion is false, so aws_instance.bastion is an empty tuple.
Cause:
Accessing aws_instance.bastion[0].id when count = 0 causes an index out-of-bounds error.
Fix:
Use the built-in one() function or the splat operator with try():
# Option A: Built-in one() (Returns the single item, or null if list is empty)
output "instance_id" {
value = one(aws_instance.bastion[*].id)
}
# Option B: try() with fallback
output "public_ip" {
value = try(aws_instance.bastion[0].public_ip, "Disabled")
}
🧹 Clean Teardown
To avoid incurring any lingering costs for the bastion instance, tear down the environment:
tofu destroy -auto-approve
Verify that all 15 resources have been completely removed:
Destroy complete! Resources: 15 destroyed.
Verify that your state file is empty:
tofu state list
(Output should be completely blank)
📌 Architectural Takeaways
for_eachis the gold standard for resource collections: Always preferfor_eachwith maps of objects for multi-instance resources like subnets, route tables, and security group rules. It guarantees predictable addressing and eliminates the devastating “Index Shift” bug.- Reserve
countfor binary toggles: Usecount = var.enabled ? 1 : 0to turn optional platform components (bastion hosts, NAT Gateways, VPN connections) on or off. dynamicblocks empower reusable modules: Use dynamic blocks to generate nested repeated blocks (ingress,egress,tag,lifecycle_rule) from callers’ variable maps without hardcoding.- HCL Map Filters (
for ... if) streamline orchestration: Filter collections inline (for k, v in var.subnets : k => v if v.tier == "public") to cleanly bind subsets of resources without duplicating definitions.
In Lab 205, we will explore Resource Lifecycle and Safe Changes (create_before_destroy, prevent_destroy, ignore_changes, and zero-downtime updates).