Playbook - TF-PRO HashiCorp Terraform Authoring and Operations Professional
Last reviewed: May 2026
A scannable reference of architectural patterns the TF-PRO exam tests. Read top-to-bottom, or jump to a section.
HCL and Configuration
A resource needs a variable number of identical nested blocks (e.g. ingress rules) driven by a list/map.
Use a `dynamic` block whose `for_each` iterates the collection; reference each element via the block iterator (default name = block label) inside `content {}`.
Why: Dynamic blocks generate repeated nested blocks without copy-paste; the iterator keeps each generated block bound to its source element.
Create one resource per entry in a map of objects, keyed stably so reordering never forces replacement.
Set `for_each = var.objects` (a map). Use `each.key` for the stable key and `each.value.<attr>` for fields. Avoid `count` here - index shifts cause churn.
Why: Map keys are stable identities in state; list indices are positional and shift when elements are added/removed.
Decide between count and for_each for multiple instances.
Use `for_each` when instances have distinct identities (a set/map); use `count` only for N identical, order-insensitive copies. Prefer for_each for anything that may grow/shrink.
Why: for_each addresses by key (resource["key"]); count addresses by index (resource[0]) which reshuffles on insertions/deletions.
An input variable is an object where some attributes are optional and need defaults.
Type it as `object({ name = string, size = optional(number, 10) })`. `optional(type, default)` supplies the default when the caller omits the attribute.
Why: optional() with a default keeps callers terse while guaranteeing a concrete value downstream - no null-handling everywhere.
Validate an assumption about a resource before applying, or guarantee a result after.
Use `lifecycle { precondition { ... } }` to assert inputs before create/update, and `postcondition` to assert outputs after. Both take `condition` + `error_message`.
Why: Custom conditions fail fast with a clear message instead of producing a broken apply or a confusing downstream error.
A resource must be recreated whenever another resource or attribute changes.
Add `lifecycle { replace_triggered_by = [aws_x.y.id] }`. When the referenced value changes, Terraform forces replacement of this resource.
Why: Expresses a replacement dependency declaratively, avoiding manual `-replace` on every related change.
Replacing a resource causes downtime because the old one is destroyed before the new one exists.
Set `lifecycle { create_before_destroy = true }` so Terraform provisions the replacement first, then destroys the old. Ensure unique names/no hard conflicts.
Why: Zero-downtime replacement; but watch for name collisions and quota limits while both exist briefly.
Reject invalid input values early (e.g. an environment that is not dev/stage/prod).
Add a `validation { condition = contains(["dev","stage","prod"], var.env), error_message = "..." }` block to the variable.
Why: Catches bad input at plan time with a readable message instead of failing deep in a provider call.
Reference a value that may not exist without crashing the plan.
Use `try(local.maybe.value, "default")` to fall back on errors, or `can(expr)` to get a boolean of whether an expression succeeds.
Why: Graceful handling of optional/variable-shaped data; avoids "Error: Unsupported attribute" on absent keys.
Transform a list into a map, or filter/shape a collection for a resource argument.
Use a `for` expression: `{ for u in var.users : u.name => u.role if u.active }` (map) or `[for x in list : upper(x)]` (list).
Why: for expressions are the idiomatic way to reshape data; the `if` clause filters, `k => v` form builds maps.
A variable or output contains a secret that should not print in plan/apply output.
Mark the variable `sensitive = true` (and outputs too). Terraform redacts it in CLI output, though it is still stored in state.
Why: Prevents accidental disclosure in logs/CI output; state itself must still be protected (encrypted backend, access control).
Manage resources in two regions/accounts within one configuration.
Declare aliased providers (`provider "aws" { alias = "west" region = "us-west-2" }`) and set `provider = aws.west` on resources or pass into modules.
Why: Aliases let one config target multiple provider instances; modules receive them explicitly via the `providers` argument.
A hidden dependency (not expressed through references) causes ordering problems.
Add `depends_on = [aws_iam_role_policy.x]` to force ordering. Use sparingly - prefer implicit dependencies via attribute references.
Why: Explicit depends_on handles dependencies the graph cannot infer, but overuse creates conservative, slower plans.
Render a config file/user-data from a template with structured variables.
Use `templatefile("${path.module}/tpl.tftpl", { items = local.items })`; the template uses `%{ for }` / `${}` interpolation.
Why: templatefile keeps rendering pure/at plan time (unlike the deprecated template provider) and supports loops/conditionals.
Build a flat list of every (subnet, rule) combination to feed a single for_each.
Use `setproduct(var.subnets, var.rules)` for the cross-product, or `flatten([for ...])` to collapse nested lists into one.
Why: These functions turn nested data into the flat, uniquely-keyable collection for_each requires.
Modules
A registry module changes and unexpectedly alters infrastructure on the next init.
Pin with `version = "~> 4.2"` (registry modules only). For Git sources, pin a `?ref=v4.2.0` tag. Run `terraform init -upgrade` deliberately to move pins.
Why: Unpinned modules float to the latest; pinning makes upgrades intentional and reviewable.
A root module needs a value produced deep inside a child module.
Expose it as an `output` in the child, then reference `module.child.output_name`. Values not output are not accessible to callers.
Why: Modules are encapsulated; outputs are the only way data crosses the module boundary upward.
Instantiate the same module once per team/environment from a map.
Set `for_each` on the module block: `module "env" { for_each = var.envs; source = "./env"; name = each.key }`. Reference `module.env["prod"]`.
Why: for_each on modules scales a pattern without copy-pasting blocks; keys give stable addresses.
A child module must create resources in a non-default (aliased) provider.
Pass providers explicitly: `module "x" { providers = { aws = aws.west } }`. The child declares the provider in `required_providers` with `configuration_aliases`.
Why: Modules do not inherit aliased providers implicitly; the providers map wires the parent alias to the child.
Renaming a resource or moving it into a module would normally destroy and recreate it.
Add a `moved { from = aws_instance.old; to = module.compute.aws_instance.new }` block. Terraform updates state addresses with no destroy.
Why: moved blocks make refactors safe and reviewable in code, replacing manual `terraform state mv`.
A monolithic module has grown unwieldy and mixes networking, compute, and data concerns.
Decompose into focused child modules and compose them in a root, passing outputs of one as inputs to the next. Keep modules single-purpose.
Why: Composition improves reuse and testability; tightly-scoped modules version and evolve independently.
Consumers pass invalid combinations of inputs to a shared module.
Add `validation` blocks and `precondition`s inside the module to enforce contracts, and document inputs with `description`.
Why: A module owns its contract; validating inside protects every caller, not just one root config.
Deeply nested modules make data flow and provider passing hard to follow.
Keep nesting shallow (1-2 levels). Pass providers and key inputs explicitly at each level; avoid relying on deep implicit inheritance.
Why: Shallow trees are easier to reason about; deep nesting amplifies provider-passing and output-plumbing complexity.
Publish a reusable module to the private registry and evolve it without breaking callers.
Tag releases with semver (`v1.2.0`); breaking input/output changes bump the major version. Callers pin with `~>` constraints.
Why: Semantic versioning lets consumers adopt fixes/features safely and opt into breaking changes deliberately.
Choose where to source a module from for a given maturity level.
Local paths (`./modules/x`) for in-repo, Git (`git::...?ref=tag`) for shared-but-unpublished, registry (`namespace/name/provider`) for versioned/published modules.
Why: Source type matches sharing scope; only registry sources support the `version` argument and constraint resolution.
A module output carries a secret consumed by the root.
Mark the module output `sensitive = true`. Consuming it in a non-sensitive context will error until you also treat it as sensitive.
Why: Sensitivity propagates across the module boundary, preventing accidental leakage in root output.
An existing count-based resource set needs to become for_each without destroying instances.
Add `moved` blocks mapping each `resource[0]` index to the new `resource["key"]` address, then switch to for_each.
Why: moved blocks re-key state from positional to identity addressing, avoiding destroy/recreate.
CLI and State Management
You renamed a resource in config; plan now wants to destroy the old and create a new one.
Prefer a `moved` block in config. For ad-hoc/CLI fixes use `terraform state mv aws_x.old aws_x.new` to re-point the existing object.
Why: Both update the state address so Terraform sees the existing object as the renamed resource - no destroy.
Stop managing a resource with Terraform but leave it running in the cloud.
Run `terraform state rm aws_x.y`. Terraform forgets the object; it is not destroyed. Also remove its config to avoid a re-create plan.
Why: state rm detaches without deleting - useful when handing a resource to another tool/team.
Move state from a local backend to S3 (or to HCP Terraform).
Add/replace the `backend`/`cloud` block, run `terraform init` - Terraform detects the change and prompts to migrate existing state to the new backend.
Why: init orchestrates the copy; answering yes migrates state safely rather than starting empty.
Two engineers run apply against the same remote state simultaneously.
Use a backend that supports locking (S3+DynamoDB, HCP Terraform, etc.). Terraform acquires a lock per operation; the second run waits or errors.
Why: Locking prevents concurrent writes that would corrupt state. Never disable it casually.
A crashed apply left a stale lock and now every run is blocked.
Confirm no operation is actually running, then `terraform force-unlock <LOCK_ID>`. Use the ID from the error message.
Why: force-unlock clears an orphaned lock; doing it while a real operation runs risks state corruption.
Detect drift between configuration/state and real infrastructure without proposing changes.
Run `terraform plan -refresh-only` (or `apply -refresh-only` to update state). It reports differences without planning resource changes.
Why: Separates drift detection from change planning - you see what changed in the cloud before deciding to reconcile.
A resource is misbehaving and you want to recreate it without editing config.
Run `terraform apply -replace="aws_instance.web"`. This is the modern replacement for the deprecated `terraform taint`.
Why: -replace forces one resource to be destroyed and recreated on the next apply, declaratively at the CLI.
You are tempted to use -target routinely to speed up applies.
Use `-target` only for recovery from errors or surgical fixes. Avoid it as a normal workflow - it produces partial applies and can skip dependencies.
Why: Routine targeting hides dependency problems and yields incomplete state; HashiCorp documents it as an exceptional tool.
A provider moved namespaces (e.g. hashicorp/aws to a fork) and state references the old address.
Run `terraform state replace-provider registry.terraform.io/hashicorp/aws registry.example.com/org/aws`.
Why: Rewrites provider references in state so init/plan resolve the new source without recreating resources.
One configuration needs outputs produced by another configuration/workspace.
Use the `terraform_remote_state` data source (or HCP Terraform run outputs) to read another state's outputs read-only.
Why: Shares values across state boundaries without duplicating resources; only exported outputs are readable.
Backend settings (bucket, key) differ per environment and should not be hardcoded.
Leave them out of the `backend` block and pass at init: `terraform init -backend-config=prod.hcl` (or `-backend-config="key=..."`).
Why: Partial configuration keeps one config reusable across environments while supplying environment-specific backend values at init.
You need separate state for dev/stage/prod from one configuration.
Use CLI workspaces (`terraform workspace new prod`) for lightweight isolation, or separate root configs/HCP workspaces for stronger separation.
Why: Each workspace has its own state; reference `terraform.workspace` to vary naming/sizing. For strong isolation, prefer distinct backends/workspaces.
HCP Terraform Operations
Avoid storing long-lived cloud keys in HCP Terraform workspace variables.
Configure dynamic provider credentials: HCP Terraform uses OIDC/workload identity to obtain short-lived credentials from AWS/Azure/GCP/Vault per run.
Why: Eliminates static secrets; credentials are minted just-in-time and expire, shrinking the blast radius.
Integrate an external check (cost estimate, security scan) into the run pipeline.
Configure a run task at a stage (pre-plan, post-plan, pre-apply). HCP Terraform calls the external service and gates the run on its result.
Why: Run tasks extend the pipeline with third-party checks without custom CI plumbing.
Choose how runs are triggered for a workspace.
VCS-driven (commit/PR triggers plan), CLI-driven (`terraform plan/apply` against the remote), or API-driven (uploaded config). Pick per team workflow.
Why: VCS-driven suits GitOps; CLI-driven suits local iteration; API-driven suits custom pipelines. They are mutually exclusive per workspace.
Grant a team write access to staging workspaces but read-only to production.
Scope permissions at org/project/workspace level: assign team access (read/plan/write/admin) per project or workspace; use project grouping to manage at scale.
A networking workspace apply should automatically queue a run in dependent app workspaces.
Configure a run trigger: the downstream workspace subscribes to the upstream; a successful apply queues the downstream run.
Why: Run triggers chain dependent workspaces so shared infrastructure changes propagate in order.
Let non-Terraform users provision standardized infrastructure via a form.
Publish a no-code module in the private registry; users instantiate it through the UI, supplying only inputs - no HCL authoring.
Why: No-code modules democratize self-service provisioning while keeping the underlying config governed and versioned.
Share vetted modules and providers across the organization.
Publish to the HCP Terraform private registry; consumers reference `app.terraform.io/org/name/provider` with version constraints.
Why: A private registry centralizes discovery, versioning, and governance of internal modules.
Organize dozens of workspaces by team/application for permissions and variable sets.
Group workspaces into projects; apply team permissions and variable sets at the project level.
Why: Projects scale governance - you manage access and shared config per project instead of per workspace.
Continuously detect when production drifts from the configured state.
Enable health assessments (drift detection / continuous validation) on the workspace; HCP Terraform periodically refreshes and reports drift and failed assertions.
Why: Automated assessments surface drift and broken postconditions between applies, before they cause incidents.
HCP Terraform must reach infrastructure inside a private network with no public ingress.
Deploy HCP Terraform agents in the private network and assign the workspace to an agent pool; runs execute via the agent.
Why: Agents let HCP Terraform operate against private/air-gapped environments without exposing them publicly.
A bad apply corrupted state and you need to recover.
HCP Terraform keeps versioned state; roll back to a prior state version from the workspace UI/API and re-plan.
Why: Built-in state versioning provides recovery points without managing backend snapshots yourself.