---
author: Phil Stevenson
title: "Terraform: General Tips and Patterns"
date: 2025-02-14
published: true
description: "A collection of practical Terraform patterns and gotchas: for_each keys, programmatic CIDRs, environment maps, flatten patterns, Fargate limits, state mv, moved blocks, and VPC endpoints."
tags: ["aws","devops","iac","patterns","terraform","tips"]
---

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

<!--more-->

## `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:

```text
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:

```hcl
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.

```hcl
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:

```hcl
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:

```hcl
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:

```bash
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](/dev/log/terraform-cross-statefile-migration/) 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:

```hcl
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:

```hcl
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](/dev/log/vpc-endpoints-cost-comparison/) for the full endpoint setup.
