Terraform Associate common mistakes
Terraform Associate · Understand the traps and how to avoid them.
Treats `terraform validate` as a full pre-flight check - expects it to catch bad provider credentials, a non-existent AMI ID, or an attribute value the API would reject, and treats `terraform fmt` as a validator that fails the build on bad code.
Both commands run entirely locally: fmt only rewrites canonical style, and validate only checks syntax and internal consistency against the provider schema without calling any remote API. Candidates over-trust them because they are the first commands in the workflow, and they also forget that validate requires terraform init to have installed providers first.
What to do instead
Memorise the split: fmt = formatting only (use fmt -check / fmt -recursive in CI), validate = offline syntax and schema conformance after init, and only plan/apply contact the provider and surface credential or invalid-value errors.
Keeps the default local backend on a shared codebase and assumes the state file is safe because it sits on a shared drive, and assumes two engineers (or two CI jobs) applying at the same time cannot corrupt each other's work.
Local state has no locking and no remote durability, and candidates conflate 'the file is on a server' with 'the backend supports locking'. Locking is a property of the backend type, not of where the file lives.
What to do instead
Declare a remote backend that supports state locking (for example an object-storage backend paired with a lock table, or HCP Terraform) in a terraform { backend "..." { } } block, commit that block, run terraform init -migrate-state, and add terraform.tfstate* and .terraform/ to .gitignore.
Uses `count` to iterate over a list of named objects, then deletes or reorders an item in the middle of the list and is surprised that unrelated resources are planned for replacement.
count addresses instances by numeric index (aws_instance.web[0], [1], ...), so removing element 0 shifts every subsequent index and each shifted instance is now a different address than the one in state.
What to do instead
Use for_each over a map or set keyed by a stable logical name so addresses become aws_instance.web["blue"]; add/remove keys without touching siblings, and reference values through each.key / each.value.
Treats `lifecycle` arguments as interchangeable - expects `ignore_changes` to hide a difference the candidate just introduced in the configuration, or expects `prevent_destroy` to block `terraform destroy` entirely rather than only plans that would destroy that resource.
These arguments look similar and all sit in a lifecycle block, so candidates learn them as one undifferentiated 'protect the resource' feature instead of four distinct ordering and diff behaviours.
What to do instead
Learn each separately: ignore_changes suppresses drift on the listed attributes relative to prior state but does not suppress changes made in configuration; prevent_destroy fails any plan that would destroy the resource (and blocks destroy unless the block is removed); create_before_destroy only changes ordering; replace_triggered_by forces replacement when a referenced attribute changes.
Writes `version = "~> 5.0"` inside a `module` block whose `source` is a relative path like `./modules/network`, or expects a plain `terraform init` to pull a newer allowed module version into an existing working directory.
The version argument is only meaningful for registry-type sources, and init is idempotent - it reuses already-installed modules and the dependency lock file rather than upgrading them, so the constraint appears ignored.
What to do instead
Only use version with registry modules; pin git sources with ?ref=<tag> and local paths with the path itself. Run terraform init -upgrade when you intend to move to a newer allowed version, and commit .terraform.lock.hcl so installs are reproducible.
Assumes code inside a child module can read root-level variables or reference resources declared in the root module, and omits an `output` block in the module, then wonders why the value is empty or the reference is invalid.
Modules are isolated namespaces; the only inputs are declared variable blocks passed as arguments, and the only things visible to the caller are declared output blocks. Candidates reason as if the configuration were one flat file.
What to do instead
Pass every value explicitly as a module argument, declare output blocks for anything the caller needs, and read them as module.<name>.<output>. Use terraform init/validate after editing a module's interface to catch missing arguments early.
Tries to fix a resource that was renamed in configuration by running `terraform import`, or runs `terraform state rm` expecting the real cloud resource to be deleted.
Import and state surgery are often learned as one 'fix the state' topic. Import only writes an existing remote object into state (it does not create, modify, or generate matching configuration), and state rm only removes the binding between an address and an object.
What to do instead
Use terraform state mv (or a moved block) to re-address a renamed or relocated resource, terraform import / import blocks to adopt pre-existing objects and then write matching configuration, and remember that removing something from state leaves the real object intact - delete it through configuration plus apply, or in the console if it is now unmanaged.
Writes `provider "aws" { version = "5.0" }` and treats `required_version` as the place to pin provider versions.
Older tutorials and provider documentation used an inline version argument, which was removed in Terraform 0.13; candidates carry that pattern forward and misread required_version as a general 'pin everything' setting.
What to do instead
Pin the CLI with terraform { required_version = "~> 1.6" }, pin providers inside terraform { required_providers { aws = { source = "hashicorp/aws", version = "~> 5.0" } } }, and pin modules with version on the module block. Keep provider blocks for configuration only.
Confuses an HCP Terraform workspace with a CLI workspace created by `terraform workspace new`, and assumes a sensitive workspace variable can be read back in the UI or API after saving, or that local untracked files are available to a remote run.
The word 'workspace' is overloaded: in HCP Terraform it is the container holding state, variables, and VCS/run settings, whereas a CLI workspace is only an extra named state instance inside one configuration. Sensitive values are write-only, and remote runs execute on HCP's runners using only what the workspace and VCS provide.
What to do instead
Treat the HCP Terraform workspace as the unit of state + variables + run environment, with CLI workspaces only relevant inside it if the configuration uses them. Set credentials as sensitive variables (or variable sets for reuse across workspaces), plan for write-only sensitive values with a rotation path, and keep anything a run needs in VCS or in workspace variables - never rely on local, untracked files.
Expects Terraform to automatically revert changes made by hand in the cloud console, or believes that creating a resource manually will show up in the configuration file.
Terraform is declarative and one-directional: configuration is the desired state, state is a record of what Terraform last observed, and nothing is written back to .tf files. Drift is only detected during refresh/plan; no correction happens until an apply runs.
What to do instead
Run terraform plan regularly (or on a schedule in CI) to surface drift, use terraform plan -refresh-only / terraform apply -refresh-only to reconcile state with reality without changing infrastructure, then edit configuration to codify the intended end state - and use import + configuration for resources that were created out of band.