Day 10: Mastering Loops and Conditionals in Terraform

Day 10: Mastering Loops and Conditionals in Terraform

Dynamic blocks, and conditionals in Terraform — the four tools that turn rigid infrastructure configs into something that actually scales.

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 (was bob) — 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.

Share This Article

Did you find this helpful?

💬 Comments

No comments yet. Be the first to share your thoughts!

Leave a Comment

Get In Touch

I'm always open to discussing new projects and opportunities.

Location Yassa/Douala, Cameroon
Availability Open for opportunities

Connect With Me

Send a Message

Have a project in mind? Let's talk about it.