Skip to content

HCL, Collections, Variables and Resource Identity ​

Engineering focus: Design reusable Terraform for multiple services. When do you use variables, locals, data sources, count, for_each, dynamic, validation and lifecycle?

Typed contracts ​

hcl
variable "environment" {
  type = string
  validation {
    condition = contains(["dev", "stage", "prod"], var.environment)
    error_message = "Use a supported environment."
  }
}
variable "services" {
  type = map(object({ port = number, replicas = number, public = bool }))
  validation {
    condition = alltrue([
      for s in values(var.services) : s.port >= 1 && s.port <= 65535 && s.replicas >= 1
    ])
    error_message = "Ports and replicas must be valid."
  }
}
locals {
  public_services = {
    for name, service in var.services :
    name => service if service.public
  }
  tags = { ManagedBy = "terraform", Environment = var.environment }
}

Inputs are contracts; locals normalize or derive values; outputs expose narrow stable module interfaces. Prefer readable transformations to opaque expression chains.

for_each versus count ​

hcl
resource "aws_s3_bucket" "logs" {
  for_each = toset(["catalog", "checkout", "billing"])
  bucket = "example-marketplace-logs-${each.key}"
}

Addresses aws_s3_bucket.logs["checkout"] remain stable when another key is removed. A count-based list indexes [0], [1], etc.; removing a middle element can shift identities and cause unexpected replacements. Use count for genuinely ordinal or optional instances, for_each for independently named infrastructure.

Data versus resource ​

hcl
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
data "google_project" "current" { project_id = "marketplace-prod" }

Data sources read externally managed objects. Prefer direct resource references for objects created in the same module; unnecessary data reads increase API calls and may be deferred to apply when inputs are unknown.

Dynamic blocks ​

hcl
resource "aws_security_group" "api" {
  name = "example-api"
  vpc_id = aws_vpc.main.id
  dynamic "ingress" {
    for_each = var.allowed_cidrs
    content {
      from_port = 443
      to_port = 443
      protocol = "tcp"
      cidr_blocks = [ingress.value]
    }
  }
}

dynamic expands nested schema blocks, not arbitrary provider configurations. Distinct AWS security group rule resources may be easier to manage than inline rules when teams have separate ownership.

Conditions and lifecycle ​

hcl
lifecycle {
  prevent_destroy = true
  precondition {
    condition = var.environment != "prod" || var.private_networking
    error_message = "Production needs private networking."
  }
}

This is an illustrative lifecycle fragment inside a managed resource. Validation handles input contracts; preconditions enforce required assumptions; postconditions validate evaluated results. check blocks express broader assertions and typically warn rather than stop changes. prevent_destroy only works while present in the resource configuration. Use IAM, cloud delete protection and CI policy as independent safety layers.

ignore_changes should name only fields intentionally owned by another controller. Broad ignore_changes = all hides real drift.

Incident: list refactoring accidentally replaces a bucket ​

A resource keyed by count.index reads names from an array. Removing one middle entry shifts all subsequent indexes. A plan then updates or replaces unrelated resources. Migrate to stable keys, with moved blocks for already managed objects, after testing the mapping.

Key takeaways ​

I use typed variables and outputs as module contracts, locals for readable transformations and data sources for objects owned elsewhere. Stable for_each keys are important because Terraform resource addresses carry lifecycle identity. I apply lifecycle rules narrowly and review refactoring plans carefully. Rather produce clear, maintainable configuration than clever expressions that obscure the change.

Expressions · Lifecycle

Practical engineering knowledge, openly shared.