Terraform Professional study materials
Terraform Professional study resources

Terraform Professional common mistakes

Terraform Professional · Understand the traps and how to avoid them.

Mistake 1

Inside a dynamic block's content, referencing `each.key`/`each.value` (or the enclosing resource's for_each variable) instead of the block's iterator name, and giving both nested dynamic blocks the same default iterator name.

The default iterator name for dynamic "ingress" is ingress, so the value is ingress.value; each belongs to the resource/module-level for_each and does not exist inside the block. When two dynamic blocks are nested, the inner block's iterator shadows the outer name, so the outer value becomes unreachable and the plan fails or produces wrong attributes.

What to do instead

Reference the label-named iterator, or set iterator = rule explicitly. For nesting, give the inner block a distinct iterator (iterator = port) and index through the outer one, e.g. rule.value.ports inside the inner content.

Mistake 2

Passing a `list(string)` directly to `for_each` on a resource or module, or switching a list-driven resource to for_each and still expecting `count.index` semantics and stable identities when items are inserted.

for_each accepts only a map or a set of strings; a list produces 'The given for_each argument value is unsuitable'. Conversely, list position is not identity, so count-based instances are destroyed and recreated whenever an element is inserted or reordered - the classic cause of accidental resource replacement.

What to do instead

Use for_each = toset(var.names) where the value itself is the identity, or build a keyed map: for_each = { for item in var.items : item.name => item }. Reserve count for genuinely interchangeable instances and derive names from count.index.

Mistake 3

Editing the `backend` or `cloud` block in configuration and running plain `terraform init`, or adding `-reconfigure` when the intent was to move existing state to the new backend.

-reconfigure tells Terraform to discard the stored backend association and start fresh, so previously managed objects appear to disappear and the next plan proposes to recreate everything. Plain init on a changed backend halts with 'Backend configuration changed' until you choose a migration path.

What to do instead

Run terraform init -migrate-state to copy existing state into the new backend and confirm the prompt; reserve -reconfigure for when the old state is intentionally irrelevant. Take terraform state pull > backup.tfstate before migrating, and note that partial backend configuration with -backend-config still requires the same migration choice.

Mistake 4

Using `terraform state rm` to 'delete' a resource, or `terraform state mv` to rename one, expecting the real infrastructure to be destroyed or renamed - and running these without checking `-dry-run` or taking a backup.

State commands only rewrite the mapping between addresses and real objects. state rm makes Terraform forget the object, which continues to exist and be billed; state mv changes only the address, not the provider-side identifier. Hand-editing or pulling state while another run holds the lock risks corrupting or clobbering it.

What to do instead

Preview with -dry-run, back up with terraform state pull > backup.tfstate, and prefer declarative moved blocks (Terraform >= 1.1) for refactors so the change is reviewed in a plan. Use terraform import/import blocks to bring an object back under management rather than assuming state rm deleted it.

Mistake 5

Adding a `version = "~> 2.1"` argument to a module whose `source` is a git URL, S3/GCS bucket, or local path, and assuming the module is now pinned to that release.

The version argument is only honored for modules installed from a registry (public or private/HCP Terraform). For every other source type it is ignored, so the module silently follows whatever the source currently points at - commonly a moving branch like main.

What to do instead

Pin non-registry sources in the source string itself: git::https://example.com/mod.git?ref=v2.1.0 for git, or a versioned object key for object storage. Keep the version argument for app.terraform.io/<org>/<name>/<provider> sources, or for private registry modules used through a registry source address.

Mistake 6

Declaring an aliased `provider "aws" { alias = "west" }` block inside a child module and expecting it to take effect, or calling a module that needs a non-default provider without passing `providers = { aws = aws.west }`.

Provider configurations cannot be declared in child modules - a module only receives provider configurations from its caller via the providers argument. Without it, the alias never reaches the child, so the child either errors or silently uses the default provider and deploys to the wrong region.

What to do instead

Declare only required_providers in the child, configure providers in the root, and pass them explicitly with keys matching the child's provider references: providers = { aws = aws.west }. Remember that a module with count or for_each set must supply the providers map explicitly.

Mistake 7

Creating a workspace variable named `region` in HCP Terraform's environment-variable category and expecting `var.region` to resolve, or marking a variable sensitive and then trying to read its value back from the UI or API.

Terraform variables and environment variables are separate namespaces. Only Terraform-category variables populate var.<name>; an environment variable reaches a variable only when named TF_VAR_<name>. Sensitive values are write-only after creation - the API and UI never return them, so a lost value must be replaced, not retrieved.

What to do instead

Put anything referenced as var.x in the Terraform variable category and use environment variables for provider/CLI credentials (e.g. AWS_ACCESS_KEY_ID). Store shared secrets in a variable set scoped to the workspaces that need them, decide sensitivity at creation time, and audit via the run's variable list rather than expecting to read values back.

Mistake 8

Reading `~> 1.5` as '1.5.x only', committing a `.terraform.lock.hcl` produced on a single platform and having CI on a different platform fail verification, or running `terraform init -upgrade` in CI to resolve a version conflict.

The pessimistic operator applies at the last specified segment: ~> 1.5 allows any 1.x at or above 1.5.0, while ~> 1.5.0 caps below 1.6.0. The lock file records provider package hashes per platform, so a darwin_arm64-only lock fails checksum verification on linux_amd64. -upgrade silently selects newer versions, destroying reproducibility.

What to do instead

Use ~> 1.5.0 when you want patch-level pinning, and pin the CLI with required_version. Generate a portable lock with terraform providers lock -platform=linux_amd64 -platform=darwin_arm64 -platform=windows_amd64, commit the lock file, and upgrade deliberately with init -upgrade followed by a reviewed diff of the lock file.

Mistake 9

Marking a variable `sensitive = true` and treating that as protecting the value, then feeding that sensitive value into `for_each` or `count` and expecting the plan to succeed.

Sensitivity only redacts display in CLI output and the UI; the underlying value is still written in plain text to state and to any saved plan file, which anyone with backend read access can see. Terraform also rejects sensitive values as for_each/count keys because those keys appear in the plan output.

What to do instead

Protect the value at the storage layer - encrypted remote backend (S3 with SSE-KMS and a restrictive bucket policy, or HCP Terraform encrypted state) plus least-privilege state access - rather than relying on sensitive. Where a sensitive string must be used as a key, derive a non-sensitive identifier deliberately and wrap it with nonsensitive() only after confirming it is safe to expose.

Mistake 10

Writing `.tftest.hcl` runs with `command = apply` against the default workspace and assuming nothing is left behind, omitting `error_message` on `assert` blocks, or pointing `expect_failures` at an `assert` block instead of at a variable validation or precondition.

An apply-based test run creates real infrastructure in the configured backend and destroys it only after the run completes - a mid-run failure can leave resources standing and costing money. error_message is a required argument of assert, and expect_failures accepts references to checkable objects (variable validation blocks, output preconditions, resource preconditions/postconditions), not arbitrary assertions.

What to do instead

Prefer command = plan or mock_provider for unit-style tests, supply both condition and error_message in every assert, and target expect_failures = [var.instance_type] at the validation block's object. Run any apply-based tests against a disposable workspace or scratch backend, and treat terraform test as a gate in CI alongside terraform validate and terraform fmt -check.