← Back to blog

Terraform: General Tips and Patterns

· Phil Stevenson
aws devops iac patterns terraform tips

A collection of practical Terraform patterns and gotchas picked up from real-world usage.

for_each Keys Must Be Known Before Apply

When using for_each, the map keys must be values Terraform can compute during the plan phase. If you pass a list or map containing values generated at apply time (e.g. resource IDs), Terraform will error:

The given "for_each" argument value is unsuitable: the "for_each" argument
must be a map, or set of strings, and you have provided a value of type
list of dynamic.

The fix: use a value known before apply (e.g. availability zone, a name, an index) as the map key, and the dynamic value as the map value.

In this example, subnet.availability_zone is known at plan time, but subnet.id is not:

locals {
  # availability_zone is known before apply — subnet.id is not
  eks_efs_mount_target_subnet_ids = {
    for subnet in module.vpc.private_subnet_objects :
    subnet.availability_zone => subnet.id
  }
}

resource "aws_efs_mount_target" "eks_efs_mount_target" {
  for_each        = local.eks_efs_mount_target_subnet_ids
  file_system_id  = aws_efs_file_system.eks_efs.id
  subnet_id       = each.value
  security_groups = [aws_security_group.eks_efs.id]
}

This pattern works because Terraform only needs the keys to be stable; it uses them to identify resources in state. The values can be dynamic.

Programmatic CIDR calculation with hashicorp/subnets/cidr

Rather than hardcoding subnet CIDRs, use the hashicorp/subnets/cidr module to calculate them from a base CIDR block. This keeps subnet layouts consistent and eliminates manual CIDR arithmetic.

locals {
  vpc_cidr = "10.0.0.0/20"

  # Define subnet names and sizes relative to the base CIDR
  # new_bits is added to the base prefix: /20 + 4 = /24, /20 + 3 = /23
  networks = [
    { name = "public-a",  new_bits = 4 },
    { name = "public-b",  new_bits = 4 },
    { name = "public-c",  new_bits = 4 },
    { name = "private-a", new_bits = 3 },
    { name = "private-b", new_bits = 3 },
    { name = "private-c", new_bits = 3 },
  ]
}

module "subnets" {
  source  = "hashicorp/subnets/cidr"
  version = "1.0.0"

  base_cidr_block = local.vpc_cidr
  networks        = local.networks
}

# Access calculated CIDRs by name
output "public_a_cidr" {
  value = module.subnets.network_cidr_blocks["public-a"] # e.g. "10.0.0.0/24"
}

Avoid hardcoding environment-specific values; use a local map

Instead of using var.environment directly in resource names and repeating conditionals, define a single map of all environment-specific values and look them up:

locals {
  environment = var.environment

  config = {
    dev = {
      instance_type  = "t3.small"
      min_capacity   = 1
      max_capacity   = 2
    }
    staging = {
      instance_type  = "t3.medium"
      min_capacity   = 2
      max_capacity   = 4
    }
    prod = {
      instance_type  = "m5.large"
      min_capacity   = 3
      max_capacity   = 10
    }
  }

  env = local.config[local.environment]
}

resource "aws_instance" "app" {
  instance_type = local.env.instance_type
}

This scales cleanly as you add more environments or config values; no long chains of var.environment == "prod" ? x : y.

Use flatten + for to Build Resources from Nested Structures

When you need to create resources from a nested data structure (e.g. multiple rules per group, multiple subnets per tier), use flatten with a nested for to produce a flat list:

locals {
  subnet_tiers = {
    public  = { new_bits = 4, azs = ["a", "b", "c"] }
    private = { new_bits = 3, azs = ["a", "b", "c"] }
  }

  subnets = flatten([
    for tier, config in local.subnet_tiers : [
      for az in config.azs : {
        name     = "${tier}-${az}"
        new_bits = config.new_bits
      }
    ]
  ])
}

# subnets = [
#   { name = "public-a",  new_bits = 4 },
#   { name = "public-b",  new_bits = 4 },
#   ...
#   { name = "private-a", new_bits = 3 },
# ]

terraform state mv for Cross-Statefile Migrations

When splitting or consolidating Terraform state files, use state mv with -state and -state-out to move resources between local copies of the state before pushing either back:

terraform state mv \
  -state=./working/source.tfstate \
  -state-out=./working/target.tfstate \
  module.my_resource \
  module.my_resource

Always work on local copies; never run state mv directly against the remote backend. See the Cross Statefile Migration post for the full pattern.

Use moved Blocks Instead of state mv for Same-Statefile Refactoring

When renaming or restructuring resources within the same state file, prefer a moved block over terraform state mv. It’s declarative, version-controlled, and doesn’t require manual CLI operations:

moved {
  from = aws_security_group.old_name
  to   = aws_security_group.new_name
}

Terraform handles the rename during the next apply. Once you’re confident the change has been applied everywhere, remove the moved block.

Gateway vs Interface VPC Endpoints

S3 and DynamoDB support free Gateway Endpoints that route traffic via the route table; no security group needed, no per-GB charge. Everything else requires Interface Endpoints which cost ~$8.76/month per AZ plus $0.01/GB.

Always use Gateway Endpoints for S3 and DynamoDB:

s3 = {
  service         = "s3"
  service_type    = "Gateway"
  route_table_ids = flatten([module.vpc.private_route_table_ids])
}

dynamodb = {
  service         = "dynamodb"
  service_type    = "Gateway"
  route_table_ids = flatten([module.vpc.private_route_table_ids])
}

See the VPC Endpoints Cost Comparison post for the full endpoint setup.