After Day 9, the FastAPI deployment was working: three environments, a versioned module, controlled rollouts. But the code had a problem that only became obvious when I went to add a new ingress rule to the security group.
count, for_each, dynamic blocks, and conditionals — the four tools that turn rigid infrastructure configs into something that actually scales.
I had to add it in three places. Dev, staging, prod — each with its own copy of the security group definition. Same with the S3 bucket configuration. Same with IAM users when I needed to add a new team member. Every change meant hunting through the same block repeated N times and hoping I hadn't missed one.
That's not infrastructure as code — that's infrastructure as copy-paste. Loops and conditionals are how you fix it.
How is this used in the Tech Industry
In real teams, Terraform configs grow fast. You start with one environment and six resources. Six months later it's four environments, forty resources, and a main.tf that's 800 lines of nearly identical blocks. Every change is risky because you're touching the same logic in multiple places.
for_each and count let you define a resource once and have Terraform create as many instances as you need. A new team member is a new entry in a list. A new environment is a new map key. A new ingress rule is one line, not three.
Conditionals solve a different problem: not repetition, but branching. Dev doesn't need a CloudWatch alarm. Prod does. Without conditionals you end up with separate configs for prod and non-prod — which defeats the point of having a shared module.
Together, these four features (count, for_each, for expressions, and conditionals) are what make Terraform configs maintainable as infrastructure grows.
count: Deploy N Copies of a Resource
The simplest loop is count. Set it to a number and Terraform creates that many instances of the resource.
A common use case: provisioning IAM users for a team.
variable "team_members" {
description = "List of IAM usernames to create"
type = list(string)
default = ["alice", "bob", "charlie"]
}
resource "aws_iam_user" "team" {
count = length(var.team_members)
name = var.team_members[count.index]
}
count.index gives you the position in the list — 0, 1, 2. You use it to pull the right value out of the variable.
To reference the resources Terraform creates, you use bracket notation:
output "team_arns" {
value = aws_iam_user.team[*].arn # [*] expands to a list of all ARNs
}
The count Problem: Deletion by Index
count has a sharp edge. Resources are tracked by index. If the list is ["alice", "bob", "charlie"] and you remove "bob", Terraform sees:
- index 0:
alice— no change - index 1:
charlie(wasbob) — replace - index 2: gone — destroy
It doesn't know bob was removed — it just sees that index 1 changed. The result is Terraform destroying and recreating charlie even though nothing changed for that user.
For anything where order matters or items get removed, use for_each instead.
for_each: The Right Tool for Named Resources
for_each iterates over a map or set, tracking resources by key rather than index. Removing an entry only affects that specific resource — nothing else gets touched.
Over a set of strings
variable "team_members" {
type = set(string)
default = ["alice", "bob", "charlie"]
}
resource "aws_iam_user" "team" {
for_each = var.team_members
name = each.value
}
Now if you remove "bob", only Bob's IAM user is destroyed. Alice and Charlie are untouched.
Over a map
Maps let you carry extra data per item — not just a name but also a configuration value.
variable "app_buckets" {
description = "Buckets to create, keyed by purpose"
type = map(string)
default = {
logs = "private"
backups = "private"
assets = "public-read"
}
}
resource "aws_s3_bucket" "app" {
for_each = var.app_buckets
bucket = "mnourdine-fastapi-${each.key}-${var.environment}"
tags = {
Purpose = each.key
Environment = var.environment
}
}
resource "aws_s3_bucket_acl" "app" {
for_each = var.app_buckets
bucket = aws_s3_bucket.app[each.key].id
acl = each.value # "private" or "public-read"
}
each.key is the map key (logs, backups, assets). each.value is the map value ("private", "public-read"). Both are available on every resource created by for_each.
To reference a specific bucket:
aws_s3_bucket.app["logs"].arn
To get all bucket ARNs:
output "bucket_arns" {
value = { for k, v in aws_s3_bucket.app : k => v.arn }
}
for Expressions: Transform Lists and Maps Inline
for expressions let you reshape data without creating resources. They're useful in locals, variable defaults, and output blocks.
Transform a list:
variable "environments" {
type = list(string)
default = ["dev", "staging", "prod"]
}
locals {
# ["DEV", "STAGING", "PROD"]
env_upper = [for e in var.environments : upper(e)]
# { dev = "dev-api", staging = "staging-api", prod = "prod-api" }
service_names = { for e in var.environments : e => "${e}-api" }
}
Filter while transforming:
variable "ingress_rules" {
type = map(number)
default = {
app = 8000
ssh = 22
}
}
locals {
# Only the non-SSH ports — used when SSH should be restricted separately
app_ports = { for name, port in var.ingress_rules : name => port if port != 22 }
}
The if at the end filters out entries that don't match.
Dynamic Blocks: Loops Inside a Resource
Sometimes you don't want to create multiple resources — you want to repeat a nested block inside a single resource. The security group from Day 9 is a perfect example.
Before: two separate ingress blocks hardcoded in the config.
# Before — hardcoded, have to edit HCL to add or remove a rule
resource "aws_security_group" "instance" {
ingress {
from_port = 8000
to_port = 8000
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
After: a dynamic block that iterates over a variable. Adding or removing a rule is now a one-line change to a map.
variable "ingress_rules" {
description = "Map of ingress port names to port numbers"
type = map(number)
default = {
app = 8000
ssh = 22
}
}
resource "aws_security_group" "instance" {
name = "${local.name_prefix}-instance-sg"
dynamic "ingress" {
for_each = var.ingress_rules
content {
description = ingress.key # "app" or "ssh"
from_port = ingress.value # 8000 or 22
to_port = ingress.value
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = local.common_tags
}
Inside a dynamic block, ingress.key and ingress.value follow the same each.key / each.value convention. The label on dynamic ("ingress") becomes the iterator name.
To open a new port — say, a metrics scraper on 9090 — just add one line to the variable:
ingress_rules = {
app = 8000
ssh = 22
metrics = 9090
}
No HCL changes needed.
Conditionals: Deploy Resources Only When Needed
The conditional expression in Terraform is the same ternary you've seen in most languages:
condition ? value_if_true : value_if_false
The most common pattern is combining it with count to create a resource zero or one times:
variable "enable_monitoring" {
description = "Whether to create CloudWatch alarms. Enable in prod, not in dev."
type = bool
default = false
}
resource "aws_cloudwatch_metric_alarm" "high_cpu" {
count = var.enable_monitoring ? 1 : 0
alarm_name = "${local.name_prefix}-high-cpu"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2
metric_name = "CPUUtilization"
namespace = "AWS/EC2"
period = 120
statistic = "Average"
threshold = 80
alarm_description = "CPU exceeded 80% for 4 minutes — check the ASG"
dimensions = {
AutoScalingGroupName = aws_autoscaling_group.web.name
}
}
In dev.tfvars:
enable_monitoring = false
In prod.tfvars:
enable_monitoring = true
Same module. Same code. The alarm only exists in prod.
You can use the same pattern for anything environment-specific: enhanced monitoring on RDS, deletion protection on databases, more aggressive health checks, access logging on the ALB or anything of your choice.
Putting It Together: Refactored FastAPI Module
Here's the updated variables.tf for the web-app module, now using loops and conditionals throughout:
variable "environment" {
type = string
}
variable "instance_type" {
type = string
default = "t2.micro"
}
variable "min_size" {
type = number
default = 1
}
variable "max_size" {
type = number
default = 2
}
variable "server_port" {
type = number
default = 8000
}
variable "health_check_path" {
type = string
default = "/health"
}
variable "health_check_grace_period" {
type = number
default = 300
}
variable "user_data" {
type = string
sensitive = true
}
variable "enable_monitoring" {
description = "Create CloudWatch CPU alarm. Enable in prod."
type = bool
default = false
}
variable "extra_ingress_ports" {
description = "Additional ports to open on the instance security group, beyond the app port and SSH."
type = map(number)
default = {}
}
And the updated main.tf, with the dynamic security group and conditional alarm:
locals {
name_prefix = "web-app-${var.environment}"
common_tags = {
Environment = var.environment
ManagedBy = "Terraform"
Module = "web-app"
}
# Merge the fixed rules (app + ssh) with any caller-supplied extras
all_ingress_rules = merge(
{
app = var.server_port
ssh = 22
},
var.extra_ingress_ports
)
}
resource "aws_security_group" "instance" {
name = "${local.name_prefix}-instance-sg"
dynamic "ingress" {
for_each = local.all_ingress_rules
content {
description = ingress.key
from_port = ingress.value
to_port = ingress.value
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = local.common_tags
}
resource "aws_cloudwatch_metric_alarm" "high_cpu" {
count = var.enable_monitoring ? 1 : 0
alarm_name = "${local.name_prefix}-high-cpu"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2
metric_name = "CPUUtilization"
namespace = "AWS/EC2"
period = 120
statistic = "Average"
threshold = 80
alarm_description = "CPU exceeded 80% for 4 minutes"
dimensions = {
AutoScalingGroupName = aws_autoscaling_group.web.name
}
}
Calling the module in prod/main.tf:
module "web_app" {
source = "git::https://github.com/mohamednourdine/terraform-modules.git//modules/web-app?ref=v1.3.0"
environment = var.environment
instance_type = var.instance_type
min_size = var.min_size
max_size = var.max_size
server_port = 8000
health_check_path = "/health"
health_check_grace_period = 360
user_data = local.fastapi_user_data
enable_monitoring = true # only in prod
extra_ingress_ports = {
metrics = 9090 # Prometheus scraper, prod only
}
}
Dev gets no alarm, no metrics port. Prod gets both. Same module version.
Things Worth Remembering from this blog
count and for_each cannot be mixed on the same resource. Pick one. If you start with count and switch to for_each later, Terraform will destroy and recreate all the resources because the state keys change.
for_each requires a map or set, not a list. If you have a list of strings, convert it: toset(var.my_list). Lists have ordered indices; sets and maps have stable keys.
Conditionals with count = 0 leave an empty list in state. To reference the resource safely when it might not exist:
# Use try() to return null if the alarm doesn't exist
output "alarm_arn" {
value = try(aws_cloudwatch_metric_alarm.high_cpu[0].arn, null)
}
Dynamic blocks can be nested. If you have a nested block inside a nested block (e.g., ingress inside rule inside a WAF resource), you can nest dynamic blocks. It gets hard to read fast — keep the variable structure flat where possible.
Quick Reference
| Feature | What it does | When to use it |
|---|---|---|
count |
Creates N copies, indexed 0..N-1 | Fixed-size groups where order is stable |
for_each |
Creates one copy per map/set key | Named resources that can be added or removed |
for expression |
Transforms a collection inline | Building locals, outputs, variable defaults |
dynamic block |
Repeats a nested block inside a resource | Security group rules, lifecycle policies, tag blocks |
condition ? a : b |
Returns a or b based on a bool |
Enabling/disabling resources per environment |
Where I'm At
The refactored module is about half the size it was before. Adding a new ingress port is a map entry. The CloudWatch alarm is conditional on a single variable. Environment differences live in tfvars, not in duplicated HCL blocks.
The code is also safer to change — because for_each tracks resources by name rather than index, adding or removing items from a map doesn't cause unexpected replacements on unrelated resources.
Next up: Terraform workspaces — another way to manage environment separation, and how it compares to the directory-per-environment approach we've been using.
This post is part of a 30-day Terraform learning journey.
💬 Comments
No comments yet. Be the first to share your thoughts!
Leave a Comment