Steven's Knowledge

Advanced Patterns

Meta-arguments (count, for_each), dynamic blocks, expressions and functions, lifecycle controls, and workspaces

Advanced Patterns

The features here aren't optional for real-world Terraform — once you're past simple configs, you'll use them daily.

count: Repeat by Number

count creates N instances of a resource:

resource "aws_subnet" "public" {
  count = length(var.availability_zones)

  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(aws_vpc.main.cidr_block, 8, count.index)
  availability_zone = var.availability_zones[count.index]

  tags = { Name = "public-${count.index}" }
}

# Reference individual instances or the whole list
output "first_subnet" {
  value = aws_subnet.public[0].id
}

output "all_subnet_ids" {
  value = aws_subnet.public[*].id        # splat expression
}

count's Big Caveat

Resources created with count are indexed by position. If you remove an item from the middle of the list, every resource after it shifts and gets recreated:

# Before: count.index 0=us-east-1a, 1=us-east-1b, 2=us-east-1c
# Remove us-east-1b. Now: 0=us-east-1a, 1=us-east-1c
# Terraform: destroy index 1 (1b), recreate index 1 as 1c, destroy index 2

Use count only for "give me N identical things" — for anything keyed by name, use for_each.

for_each: Repeat by Key

for_each creates one instance per key in a map or set:

variable "subnets" {
  type = map(object({
    cidr_block = string
    az         = string
  }))

  default = {
    "public-1a"  = { cidr_block = "10.0.1.0/24", az = "us-east-1a" }
    "public-1b"  = { cidr_block = "10.0.2.0/24", az = "us-east-1b" }
    "private-1a" = { cidr_block = "10.0.3.0/24", az = "us-east-1a" }
  }
}

resource "aws_subnet" "this" {
  for_each = var.subnets

  vpc_id            = aws_vpc.main.id
  cidr_block        = each.value.cidr_block
  availability_zone = each.value.az

  tags = { Name = each.key }
}

# Reference one by key
output "public_1a_id" {
  value = aws_subnet.this["public-1a"].id
}

Removing "private-1a" from the map destroys exactly that subnet — everyone else is untouched. This is the property that makes for_each the default choice for anything with stable identity.

Conditional Resources

Terraform has no if block — express conditionality through count or for_each:

# Create a NAT gateway only in production
resource "aws_nat_gateway" "main" {
  count = var.environment == "production" ? 1 : 0

  allocation_id = aws_eip.nat[0].id
  subnet_id     = aws_subnet.public[0].id
}

count = 0 is the idiomatic way to say "don't create this."

Dynamic Blocks

Some resources contain nested blocks (security group rules, IAM policy statements). Use dynamic to generate them from a list:

variable "ingress_rules" {
  type = list(object({
    description = string
    port        = number
    protocol    = string
    cidr_blocks = list(string)
  }))

  default = [
    { description = "HTTPS", port = 443, protocol = "tcp", cidr_blocks = ["0.0.0.0/0"] },
    { description = "SSH from bastion", port = 22, protocol = "tcp", cidr_blocks = ["10.0.0.0/16"] },
  ]
}

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

  dynamic "ingress" {
    for_each = var.ingress_rules

    content {
      description = ingress.value.description
      from_port   = ingress.value.port
      to_port     = ingress.value.port
      protocol    = ingress.value.protocol
      cidr_blocks = ingress.value.cidr_blocks
    }
  }
}

Use sparingly. A dynamic block with three static rules is harder to read than three plain rules.

Expressions & Functions

Terraform has a rich expression language. The ones you'll reach for most:

locals {
  # String interpolation
  bucket_name = "${var.project}-${var.environment}-logs"

  # Conditional
  instance_type = var.environment == "production" ? "m5.xlarge" : "t3.medium"

  # Try / null handling
  region = try(var.region, data.aws_region.current.name, "us-east-1")

  # for expression (build a list)
  bucket_arns = [for b in aws_s3_bucket.logs : b.arn]

  # for expression (build a map)
  subnet_by_az = { for s in aws_subnet.public : s.availability_zone => s.id }

  # Filtering with for
  prod_instances = [for i in aws_instance.app : i.id if i.tags.Environment == "production"]

  # Merge maps
  tags = merge(
    var.default_tags,
    { Name = local.bucket_name },
    var.extra_tags,
  )
}

Functions worth knowing:

FunctionPurpose
length(list)Count items
concat(list1, list2)Combine lists
merge(map1, map2)Combine maps (right wins)
lookup(map, key, default)Map access with fallback
coalesce(a, b, c)First non-null/non-empty
format("%s-%d", name, n)Printf-style
jsonencode(value) / jsondecode(string)JSON marshaling
cidrsubnet(prefix, newbits, netnum)Carve subnets out of a VPC CIDR
templatefile("user_data.sh.tpl", { var = val })Render a template file
file("./README.md")Read a file into a string

Lifecycle Controls

The lifecycle block changes how Terraform manages a specific resource:

resource "aws_db_instance" "main" {
  identifier        = "production-db"
  instance_class    = "db.r6g.xlarge"
  allocated_storage = 100

  lifecycle {
    # Refuse to destroy this resource — even with `terraform destroy`
    prevent_destroy = true

    # Always create the replacement before destroying the old one
    create_before_destroy = true

    # Ignore drift on these attributes (don't fight the cloud autoscaler)
    ignore_changes = [
      allocated_storage,        # storage can be scaled up out-of-band
      tags["LastBackup"],       # set by a Lambda
    ]

    # Cross-attribute validation at plan time
    precondition {
      condition     = var.environment == "production" ? var.allocated_storage >= 100 : true
      error_message = "Production DBs require at least 100 GB of storage."
    }
  }
}

When you need each:

SettingUse it when
prevent_destroyStateful resources you can never accidentally lose (prod DBs, KMS keys)
create_before_destroyReplacing a load balancer / security group; downtime is unacceptable
ignore_changesAnother system (autoscaler, deploy tool) legitimately mutates the resource
precondition / postconditionCross-cutting invariants you want enforced at plan time

Workspaces

A workspace is a named state file inside one config directory. Useful for ephemeral environments (preview PRs, dev sandboxes) where you want one codebase to produce many parallel deployments.

terraform workspace new feature-branch-42
terraform workspace list
terraform workspace select feature-branch-42
terraform workspace delete feature-branch-42

Reference terraform.workspace in HCL:

resource "aws_s3_bucket" "logs" {
  bucket = "myproject-${terraform.workspace}-logs"
}

Workspaces are tempting for staging vs. production — don't. A wrong workspace select deploys production code to staging variables (or vice versa). For long-lived environments, use separate directories with separate backends; reserve workspaces for short-lived parallel deployments.

Common Commands Cheat Sheet

# Format and validate
terraform fmt -recursive
terraform validate

# Plan / apply / destroy
terraform plan -out=tfplan
terraform apply tfplan
terraform destroy

# Target a single resource (use sparingly — usually a sign of a state problem)
terraform plan -target=aws_instance.app

# Refresh state from the cloud
terraform refresh

# Show the saved plan or current state
terraform show tfplan
terraform show

# Force a resource to be recreated on next apply
terraform apply -replace=aws_instance.app

# Show / set / inspect outputs
terraform output
terraform output -json | jq

# State surgery
terraform state list
terraform state show aws_instance.app
terraform state mv aws_instance.web aws_instance.app
terraform state rm aws_s3_bucket.logs
terraform import aws_s3_bucket.imported existing-bucket-name

What's Next

You can now write Terraform that scales. The last piece is operating it well — project layout, CI/CD, security, testing → Best Practices.

On this page