Hedronite · Dev Lesson · Polyglot-Dev / HCL · Sun 2026-09-20

HCL dynamic blocks and for_each — the ingress map that expands

Expand nested blocks from a map. Key instances by name, not by position.

Lesson Class: Dev (HCL depth · dynamic + for_each)
Language: HCL
Paired Ops: AWS SG separate rule resources
Paired Cert: Associate dynamic / for_each / count / splat
Grounding: Lab 19 · Lab 16 · Brikman referenced
for_each
One resource instance per map key. Stable addresses.
dynamic
Nested block expansion when the schema demands blocks.
count
Positional. Shrinks reshuffle. Lab 19 pain.
Name the key. Expand the block. Do not index a shrinking set.

<!-- hal:authoritative:yaml -->

Expand nested blocks from a map. Key instances by name, not by position.

§I — Frame

09-11 taught the backend block as a special HCL surface: no expressions inside, partial attributes merged at init. Leave it.

Today tf_day_dev_counter is 18. Eighteen mod 3 is 0. HCL depth. Ops wants an ingress map that becomes either nested blocks or separate rule resources without reshuffling state when one port disappears.

§II — Two expansion tools

Tool one. for_each on a resource. Creates one instance per map key (or set element). The instance address includes the key: aws_vpc_security_group_ingress_rule.rules["https"]. Removing "ssh" destroys only that instance.

Tool two. dynamic blocks. Expands nested blocks inside one resource (classic inline ingress on aws_security_group, or module blocks that expect repeated nested shapes). The iterator is for_each inside dynamic "ingress".

variable "ingress_rules" {
  type = map(object({
    from_port   = number
    to_port     = number
    protocol    = string
    cidr_ipv4   = string
    description = string
  }))
}

resource "aws_vpc_security_group_ingress_rule" "rules" {
  for_each = var.ingress_rules

  security_group_id = aws_security_group.app.id
  cidr_ipv4         = each.value.cidr_ipv4
  from_port         = each.value.from_port
  to_port           = each.value.to_port
  ip_protocol       = each.value.protocol
  description       = each.value.description
}

When you must keep inline blocks (older module contract), the same map feeds dynamic:

resource "aws_security_group" "app" {
  name   = "app-sg"
  vpc_id = aws_vpc.main.id

  dynamic "ingress" {
    for_each = var.ingress_rules
    content {
      from_port   = ingress.value.from_port
      to_port     = ingress.value.to_port
      protocol    = ingress.value.protocol
      cidr_blocks = [ingress.value.cidr_ipv4]
      description = ingress.value.description
    }
  }
}

Prefer the separate-rule for_each form when you control the module. Use dynamic when the schema forces nested blocks.

§III — Why count loses

Lab 19 exists because count = length(list) keys instances by index. Delete index 0 and every later instance moves. for_each keys by map key. Delete "ssh" and "https" keeps its address.

Rule one. Stable string keys beat positional indexes for sets that shrink.

Rule two. each.key / each.value are the only iterators inside a for_each resource. Inside dynamic, the label name (ingress) is the iterator.

Rule three. count and for_each cannot share one resource block. Pick one.

§IV — Close instruction

Type both shapes against a three-entry ingress map (https, ssh, health). Remove ssh. Confirm separate-rule plan destroys one address. Then break a count-based version on purpose and watch indexes slide. Pair: Ops owns AWS SG vs NACL shelves; Cert names Associate stems for dynamic, for_each, count, and splat.