Laptop to Production

Loops and lifecycle

for_each, count's trap, and protecting what matters.

Time
50 minutes
Level
Intermediate

Part 1: The problem

QuickCart, retiring a queue

QuickCart’s code built three message queues from one list: orders, payments and emails. Payments had moved to a new system, so Leo removed "payments" from the middle of the list.

The plan said 1 to add, 0 to change, 2 to destroy. Two destroys seemed odd for one queue, but the list was shorter and the payments queue should go, so he approved it.

That evening, customers stopped getting order confirmations. The apply had destroyed the emails queue — with every unsent email in it — and built a new, empty queue with the same name. The payments queue was gone too, as intended. Nothing in the code looked wrong.

The code used count, which tracks each copy by its position in the list. Removing an item from the middle shifts everything after it.

This lesson covers the two ways to make copies, how to repeat blocks inside one resource, and the lifecycle settings that change how Terraform replaces and destroys things.

Part 2: The idea

Without loops, three queues means three resource blocks that differ by one word. Loops let one block make several copies. There are two ways, and the difference is how each copy is remembered in the state.

Key point: count remembers copies by position; for_each remembers them by name. Use for_each, unless you’re switching one thing on or off.

count: copies by position

queues-count.tf
variable "queue_names" {
  description = "The queues to build, one per name"
  type        = list(string)
  default     = ["orders", "payments", "emails"]
}

resource "aws_sqs_queue" "by_position" {
  count = length(var.queue_names)

  name = "quickcart-${var.queue_names[count.index]}"
}
SettingWhat it doesIf you leave it outWhen you’d change it
variable "queue_names"A list(string): ordered names——
countHow many copies to make. length() counts the list, so 3One copy, with no index—
count.indexInside the block, which copy this is: 0, 1 or 2Only exists when count is set—
nameThe queue’s name in AWS. var.queue_names[count.index] picks the item at this positionAWS invents a random nameChanging it forces replacement: SQS can’t rename a queue

The copies are stored as aws_sqs_queue.by_position[0], [1] and [2]. Remove "payments" and the list becomes ["orders", "emails"]:

PositionBeforeAfterWhat the plan does
[0]ordersordersNothing
[1]paymentsemailsName changed → replace: destroy the payments queue, create a new, empty emails queue
[2]emails—Position gone → destroy the real emails queue

Terraform doesn’t know that emails “moved up”. It only compares what each position holds.

for_each: copies by name

queues.tf
variable "queue_settings" {
  description = "One entry per queue, with that queue's own settings"
  type = map(object({
    visibility_timeout_seconds = number
    message_retention_seconds  = number
  }))

  default = {
    orders = {
      visibility_timeout_seconds = 60
      message_retention_seconds  = 345600
    }
    emails = {
      visibility_timeout_seconds = 30
      message_retention_seconds  = 86400
    }
  }
}

resource "aws_sqs_queue" "queue" {
  for_each = var.queue_settings

  name                       = "quickcart-${each.key}"
  visibility_timeout_seconds = each.value.visibility_timeout_seconds
  message_retention_seconds  = each.value.message_retention_seconds
}
SettingWhat it doesIf you leave it outWhen you’d change it
variable "queue_settings"A map: each key is a queue name, each value is an object holding that queue’s settings—Add a queue by adding an entry — no new resource block
for_eachOne copy per key of a map, or per item of a set of stringsOne copy—
each.keyThe current key: "orders" or "emails"Only exists with for_each—
each.valueThe current value — here the object, so each.value.visibility_timeout_secondsOnly exists with for_each—
nameThe queue’s name: quickcart-ordersAWS invents a nameChanging it forces replacement
visibility_timeout_secondsHow long a message is hidden from other workers while one works on it30 secondsLonger than your slowest job, or messages get processed twice
message_retention_secondsHow long an unprocessed message is kept. 345600 is 4 days4 daysUp to 14 days (1209600) for things you can’t afford to lose

The copies are stored as aws_sqs_queue.queue["orders"] and aws_sqs_queue.queue["emails"]. Remove an entry and only the copy with that key is destroyed. No other address changes, so nothing else is touched.

If all you have is a list of names, turn it into a set with toset(): for_each = toset(var.queue_names). Then each.key and each.value are both the name.

count’s one safe job: on or off

dead-letter.tf
variable "enable_dead_letter_queue" {
  description = "Build a queue that catches messages which keep failing"
  type        = bool
  default     = false
}

resource "aws_sqs_queue" "dead_letter" {
  count = var.enable_dead_letter_queue ? 1 : 0

  name                      = "quickcart-orders-dead-letter"
  message_retention_seconds = 1209600
}

output "dead_letter_queue_arn" {
  description = "The dead-letter queue's ARN, or null when it is switched off"
  value       = var.enable_dead_letter_queue ? aws_sqs_queue.dead_letter[0].arn : null
}
SettingWhat it doesIf you leave it outWhen you’d change it
variable "enable_dead_letter_queue"A bool: true or false—Set it per environment in a .tfvars file
count = var.enable_dead_letter_queue ? 1 : 0condition ? a : b means “a if true, otherwise b”. One copy when on, none when offThe queue is always built—
nameThe queue’s nameAWS invents one—
message_retention_seconds1209600 is 14 days, the most SQS allows — failed messages wait for someone to look4 days—
output → valueEven one copy made with count is a list, so you refer to it as dead_letter[0]. When it’s switched off there is no [0], so the condition returns null insteadReferring to [0] while it’s off fails with Invalid index—

The number is only ever 0 or 1. There’s no middle to remove, so the trap can’t happen.

Dynamic blocks: repeating a block inside a resource

count and for_each repeat whole resources. Sometimes you need to repeat a block inside one resource, like the ingress rules of a security group. That’s a dynamic block.

security-group.tf
variable "vpc_id" {
  description = "The VPC the load balancer lives in"
  type        = string
}

variable "alb_ingress_rules" {
  description = "Who may connect to the load balancer, one entry per rule"
  type = list(object({
    port        = number
    cidr        = string
    description = string
  }))

  default = [
    {
      port        = 443
      cidr        = "0.0.0.0/0"
      description = "HTTPS from anywhere"
    },
    {
      port        = 80
      cidr        = "0.0.0.0/0"
      description = "HTTP from anywhere, redirected to HTTPS"
    },
  ]
}

resource "aws_security_group" "alb" {
  name_prefix = "quickcart-alb-"
  description = "QuickCart load balancer"
  vpc_id      = var.vpc_id

  dynamic "ingress" {
    for_each = var.alb_ingress_rules

    content {
      description = ingress.value.description
      from_port   = ingress.value.port
      to_port     = ingress.value.port
      protocol    = "tcp"
      cidr_blocks = [ingress.value.cidr]
    }
  }

  egress {
    description = "All outbound traffic"
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  lifecycle {
    create_before_destroy = true
  }
}
SettingWhat it doesIf you leave it outWhen you’d change it
variable "vpc_id"Which VPC the group belongs to. Required: no default——
variable "alb_ingress_rules"A list of rule objects: port, address range, description—Add a rule by adding an entry
name_prefixAWS adds a unique ending to this, like quickcart-alb-20260925101500123400000001. Needed for create_before_destroy, belowWith name instead, the replacement can’t be created while the old group exists—
descriptionShown in the console. Changing it forces replacementDefaults to Managed by TerraformRarely, because of the replacement
vpc_idThe VPC, from the variableThe group lands in the default VPC—
dynamic "ingress"Generate one ingress block per item——
for_each (inside dynamic)The list to loop overRequired—
contentThe template for each generated block. ingress.value is the current item — named after the block, not eachRequired—
description, from_port, to_portThe rule’s label and the port range. One port means from and to are the sameRequired by the provider—
protocoltcp here. "-1" in the egress block means every protocolRequired—
cidr_blocksWhich addresses the rule allows. 0.0.0.0/0 means anywhereThe rule allows nobodyNarrow it for anything that isn’t public
egressAllow all outbound traffic. Written out, because Terraform removes AWS’s default outbound rule from groups it managesThe group can’t send anything out—
lifecycle → create_before_destroyExplained below——
In the plan, the dynamic block has become two ordinary ingress rules
      + ingress                = [
          + {
              + cidr_blocks      = [
                  + "0.0.0.0/0",
                ]
              + description      = "HTTP from anywhere, redirected to HTTPS"
              + from_port        = 80
              + ipv6_cidr_blocks = []
              + prefix_list_ids  = []
              + protocol         = "tcp"
              + security_groups  = []
              + self             = false
              + to_port          = 80
            },
          + {
              + cidr_blocks      = [
                  + "0.0.0.0/0",
                ]
              + description      = "HTTPS from anywhere"
              + from_port        = 443
              + ipv6_cidr_blocks = []
              + prefix_list_ids  = []
              + protocol         = "tcp"
              + security_groups  = []
              + self             = false
              + to_port          = 443
            },
        ]

Don’t overuse dynamic blocks. Two fixed rules are easier to read written out. Use one when the list really varies, for example between environments. And for security groups in particular, AWS provider 6 recommends separate aws_vpc_security_group_ingress_rule resources, one per rule — which you can make with an ordinary for_each. Security groups covers both.

lifecycle: changing how Terraform replaces and destroys

By default Terraform destroys anything the code no longer asks for, replaces by destroying first, and puts every setting back to what the code says. A lifecycle block inside a resource changes those rules for that one resource.

SettingTerraform’s defaultWhat it changes toUse it for
create_before_destroyDestroy the old one, then create the new oneCreate the new one first, move things over, then destroy the old oneAnything in use during a replacement: security groups, launch templates, target groups
prevent_destroyAnything can be destroyedRefuse any plan that would destroy or replace this resourceAnything holding data: databases, buckets of customer files
ignore_changesThe code always winsSet these fields at creation, then never look at them againFields something else owns: an auto scaling count, a value a pipeline updates
replace_triggered_byReplace only when this resource’s own settings force itAlso replace when a named other resource changesRare. For example, restarting something when its configuration is replaced

create_before_destroy

The security group above has it. Changing its description forces replacement. Without the setting, Terraform tries to delete the old group first. But the load balancer is still using it, so AWS refuses with a DependencyViolation error, and the apply keeps retrying until it times out. With the setting, Terraform creates the new group, moves the load balancer onto it, and only then deletes the old one. The plan’s symbol flips to show it:

SymbolOrder
-/+Destroy, then create. The default
+/-Create, then destroy. create_before_destroy is on

One character apart, opposite meanings. It’s worth being able to read at a glance.

prevent_destroy

receipts.tf
resource "aws_s3_bucket" "receipts" {
  bucket = "quickcart-receipts-111122223333"

  lifecycle {
    prevent_destroy = true
  }
}
SettingWhat it doesIf you leave it outWhen you’d change it
bucketThe bucket’s name. Must be unique across all of AWS, so it ends with the account IDAWS invents a nameNever — changing it replaces the bucket
prevent_destroyAny plan that would destroy or replace this bucket fails, and nothing in that plan runsA destroy, or a change that forces replacement, deletes it and its filesSet to false only in the commit where you really mean to delete it

You’ll see the real error in the lab. Three limits matter:

LimitWhat it means
The value must be written as true or falseprevent_destroy = var.protect fails with Variables not allowed. You can’t switch it per environment
Deleting the resource block removes it tooThe protection lives inside the block. Delete the block and the next apply destroys the resource without complaint. Review catches that, not Terraform
It only stops TerraformSomeone in the console can still delete it. AWS-side settings like a database’s deletion_protection stop that

ignore_changes

release.tf
resource "aws_ssm_parameter" "current_release" {
  name        = "/quickcart/orders/current-release"
  description = "The image tag that is live. The release pipeline changes it"
  type        = "String"
  value       = "v1"

  lifecycle {
    ignore_changes = [value]
  }
}
SettingWhat it doesIf you leave it outWhen you’d change it
nameThe parameter’s path in Parameter StoreRequiredChanging it replaces the parameter
descriptionA note on what it’s forNo description—
typeString: plain text. SecureString is for secretsRequired—
valueThe starting valueRequired (or a write-only value)—
ignore_changes = [value]Terraform sets value when it creates the parameter, then never compares it againEvery apply resets the pipeline’s latest tag back to v1—

The same pattern stops Terraform scaling a service back down after auto scaling raised it: ignore_changes = [desired_count] on an ECS service. The cost is real: Terraform goes blind to that field. If someone sets it wrong by hand, no plan will tell you. Ignore only what something else genuinely owns. ignore_changes = all ignores every field, and is almost always a sign of a deeper problem.

Part 3: See it

Three queues built from one list, twice: with count on the left, for_each on the right. Remove the middle one and compare the two plans. Then apply them and see what’s left.

count

tracked by position
queues = ["orders", "payments", "emails"]
[0]orders
[1]payments
[2]emails
No changes. Your infrastructure matches the configuration.

for_each

tracked by name
queues = ["orders", "payments", "emails"]
["orders"]orders
["payments"]payments
["emails"]emails
No changes. Your infrastructure matches the configuration.
Three queuesThree queues, built twice: once with count, once with for_each. Look at the addresses. count files each queue under a position — 0, 1, 2. for_each files it under its name.

What to notice: both sides end up with the same two queue names. Only the plan tells you that on the count side one of them is a new, empty queue. That’s why you read every -/+ and every destroy, and ask why that one?

Part 4: Do it

Free — everything here uses terraform_data, which is built into Terraform and creates nothing in AWS. You don’t even need an AWS login.

You’ll reproduce Leo’s mistake safely, watch prevent_destroy stop a plan, and use count as an on/off switch. About 20 minutes.

terraform_data is a resource that just stores a value in the state. Its triggers_replace setting works like a queue’s name: change it, and Terraform must replace the resource. So it behaves exactly like the real queues, for free.

  1. Make the folder

    Terminal
    mkdir quickcart-loops
    PartWhat it means
    mkdirmake a directory
    quickcart-loopsits name
    Terminal
    cd quickcart-loops
    PartWhat it means
    cdchange directory — every later command runs in here
    quickcart-loopsthe folder you just made
  2. Build the same queues twice

    Create main.tf:

    main.tf
    terraform {
      required_version = ">= 1.10.0"
    }
    
    variable "queues" {
      description = "The queues QuickCart needs"
      type        = list(string)
      default     = ["orders", "payments", "emails"]
    }
    
    resource "terraform_data" "by_position" {
      count = length(var.queues)
    
      triggers_replace = var.queues[count.index]
    }
    
    resource "terraform_data" "by_name" {
      for_each = toset(var.queues)
    
      triggers_replace = each.key
    }
    SettingWhat it doesIf you leave it outWhen you’d change it
    required_versionRefuse to run on Terraform older than 1.10Older versions would try to run it—
    variable "queues"The list of queue names—In the next step
    terraform_data "by_position"Built into Terraform. Stores values in state and creates nothing anywhere——
    count = length(var.queues)One copy per position in the listOne copy—
    triggers_replaceA value that, when it changes, forces replacement — like a queue’s nameChanges never replace it—
    terraform_data "by_name" → for_each = toset(var.queues)One copy per name. toset() turns the list into a set, which for_each needs——
    Terminal
    terraform init
    PartWhat it means
    terraform initprepare the folder. terraform_data is built in, so nothing is downloaded
    You should see
    - terraform.io/builtin/terraform is built in to Terraform
    
    Terraform has been successfully initialized!
    Terminal
    terraform apply
    PartWhat it means
    terraform applyplan, then carry it out once you type yes

    Type yes.

    You should see
    Apply complete! Resources: 6 added, 0 changed, 0 destroyed.

    Now list what the state remembers:

    Terminal
    terraform state list
    PartWhat it means
    terraform state listprint the address of everything in the state
    You should see
    terraform_data.by_name["emails"]
    terraform_data.by_name["orders"]
    terraform_data.by_name["payments"]
    terraform_data.by_position[0]
    terraform_data.by_position[1]
    terraform_data.by_position[2]

    Positions on one side, names on the other.

  3. Remove the middle item and read the plan

    Change the list to drop "payments". The whole file now reads:

    main.tf
    terraform {
      required_version = ">= 1.10.0"
    }
    
    variable "queues" {
      description = "The queues QuickCart needs"
      type        = list(string)
      default     = ["orders", "emails"]
    }
    
    resource "terraform_data" "by_position" {
      count = length(var.queues)
    
      triggers_replace = var.queues[count.index]
    }
    
    resource "terraform_data" "by_name" {
      for_each = toset(var.queues)
    
      triggers_replace = each.key
    }
    Terminal
    terraform plan
    PartWhat it means
    terraform planshow what would change, without changing anything
    You should see
    Terraform will perform the following actions:
    
      # terraform_data.by_name["payments"] will be destroyed
      # (because key ["payments"] is not in for_each map)
      - resource "terraform_data" "by_name" {
          - id               = "cae2a2cf-f571-88ab-0acf-4aa53fe21eb6" -> null
          - triggers_replace = "payments" -> null
        }
    
      # terraform_data.by_position[1] must be replaced
    -/+ resource "terraform_data" "by_position" {
          ~ id               = "b51d3f5f-a9cc-d116-e770-4faca8106757" -> (known after apply)
          ~ triggers_replace = "payments" -> "emails"
        }
    
      # terraform_data.by_position[2] will be destroyed
      # (because index [2] is out of range for count)
      - resource "terraform_data" "by_position" {
          - id               = "49072b9d-30a3-8d10-6226-324b5b6f909c" -> null
          - triggers_replace = "emails" -> null
        }
    
    Plan: 1 to add, 0 to change, 3 to destroy.

    Your IDs will differ. Read it as Leo should have:

    AddressWhat the plan doesRight?
    by_name["payments"]Destroy paymentsYes
    by_position[1]Replace payments with a new emailsNo — a fresh, empty queue
    by_position[2]Destroy the real emails, because index [2] is out of rangeNo — this is the data loss

    Terraform even says why in the grey comment lines. Don’t apply yet.

  4. Watch prevent_destroy refuse

    Protect the for_each copies. The whole file:

    main.tf
    terraform {
      required_version = ">= 1.10.0"
    }
    
    variable "queues" {
      description = "The queues QuickCart needs"
      type        = list(string)
      default     = ["orders", "emails"]
    }
    
    resource "terraform_data" "by_position" {
      count = length(var.queues)
    
      triggers_replace = var.queues[count.index]
    }
    
    resource "terraform_data" "by_name" {
      for_each = toset(var.queues)
    
      triggers_replace = each.key
    
      lifecycle {
        prevent_destroy = true
      }
    }
    Terminal
    terraform plan
    PartWhat it means
    terraform plantry to plan the same change again
    Terraform refuses to plan at all
    Error: Instance cannot be destroyed
    
      on main.tf line 17:
      17: resource "terraform_data" "by_name" {
    
    Resource terraform_data.by_name["payments"] has lifecycle.prevent_destroy
    set, but the plan calls for this resource to be destroyed. To avoid this
    error and continue with the plan, either disable lifecycle.prevent_destroy or
    reduce the scope of the plan using the -target option.

    Nothing in the plan runs — not even the count side’s changes. There is no flag to force past it. To delete a protected resource you change the code, in a commit someone reviews.

  5. Apply the removal, and add an on/off switch

    Take the lifecycle block out again, and add a switched-off audit queue. The whole file:

    main.tf
    terraform {
      required_version = ">= 1.10.0"
    }
    
    variable "queues" {
      description = "The queues QuickCart needs"
      type        = list(string)
      default     = ["orders", "emails"]
    }
    
    variable "enable_audit_queue" {
      description = "Build the audit queue. Off unless someone asks for it"
      type        = bool
      default     = false
    }
    
    resource "terraform_data" "by_position" {
      count = length(var.queues)
    
      triggers_replace = var.queues[count.index]
    }
    
    resource "terraform_data" "by_name" {
      for_each = toset(var.queues)
    
      triggers_replace = each.key
    }
    
    resource "terraform_data" "audit" {
      count = var.enable_audit_queue ? 1 : 0
    
      triggers_replace = "audit"
    }
    
    output "audit_queue_id" {
      description = "The audit queue's ID, or null when it is switched off"
      value       = var.enable_audit_queue ? terraform_data.audit[0].id : null
    }
    SettingWhat it doesIf you leave it outWhen you’d change it
    variable "enable_audit_queue"A true/false switch, off by default—Per environment, in a .tfvars file
    count = var.enable_audit_queue ? 1 : 0One copy when on, none when offAlways built—
    triggers_replace = "audit"The stand-in queue name——
    output "audit_queue_id" → valueaudit[0] only exists when it’s on, so the condition returns null when it’s offFails with Invalid index while off—
    Terminal
    terraform apply
    PartWhat it means
    terraform applycarry out the removal from step 3. The switch is off, so no audit queue

    Type yes.

    You should see
    terraform_data.by_name["payments"]: Destroying... [id=cae2a2cf-f571-88ab-0acf-4aa53fe21eb6]
    terraform_data.by_position[2]: Destroying... [id=49072b9d-30a3-8d10-6226-324b5b6f909c]
    terraform_data.by_position[1]: Destroying... [id=b51d3f5f-a9cc-d116-e770-4faca8106757]
    terraform_data.by_position[2]: Destruction complete after 0s
    terraform_data.by_name["payments"]: Destruction complete after 0s
    terraform_data.by_position[1]: Destruction complete after 0s
    terraform_data.by_position[1]: Creating...
    terraform_data.by_position[1]: Creation complete after 0s [id=b7087b2c-63d1-fd73-805f-ca6454200adf]
    
    Apply complete! Resources: 1 added, 0 changed, 3 destroyed.

    Look at by_position[1]: a new ID. With real queues, that would be a new, empty emails queue.

    Now see what switching the audit queue on would do:

    Terminal
    terraform plan -var enable_audit_queue=true
    PartWhat it means
    terraform planshow what would change
    -var enable_audit_queue=trueturn the switch on, for this command only
    You should see
      # terraform_data.audit[0] will be created
      + resource "terraform_data" "audit" {
          + id               = (known after apply)
          + triggers_replace = "audit"
        }
    
    Plan: 1 to add, 0 to change, 0 to destroy.
    
    Changes to Outputs:
      + audit_queue_id = (known after apply)

    One copy, at position [0]. With 0 or 1, there’s nothing to shift.

  6. Clean-up step: Clean up

    Terminal
    terraform destroy
    PartWhat it means
    terraform destroyremove everything in the state, once you type yes
    You should see
    Destroy complete! Resources: 4 destroyed.

    Nothing was ever created in AWS, so that’s all. You can delete the folder.

Part 5: Check yourself

Queues are built with count from ["orders", "payments", "emails"]. Leo removes "payments". What does the plan do to the emails queue?

Why doesn't for_each have count's problem?

When is count still the right choice?

A security group's name must change, which forces replacement. It's attached to a live load balancer. What stops a gap where no security group exists?

The release pipeline changes an SSM parameter's value on every release. Each Terraform apply sets it back to v1. What's the fix?

A resource has prevent_destroy = true. Someone deletes the whole resource block from the code and applies. What happens?

Part 6: Cheat sheet

On one card
ThingWhat it does
count = length(var.list)Copies by position: x[0], x[1]. Removing a middle item shifts the rest
count = var.enabled ? 1 : 0On/off switch. Refer to it as x[0]
for_each = var.map or toset(var.list)Copies by key: x["orders"]. Removing one touches nothing else
each.key, each.valueThe current key and value inside a for_each
dynamic "ingress" { for_each, content }Repeat a block inside one resource; ingress.value is the item
create_before_destroy = trueReplace by creating first. Pair with name_prefix. Plan shows +/-
prevent_destroy = trueRefuse any plan that destroys or replaces it. Literal true only
ignore_changes = [field]Stop managing a field something else owns. You go blind to it
replace_triggered_by = [resource]Replace when another resource changes. Rare
-/+ in a planDestroy, then create. Stop and ask why
“because index [2] is out of range for count”The count trap. Switch to for_each

Finished the build and the quiz? Mark it done to fill in its stop on the route map.