8 high-value tips covering exam domains, tricky scenarios, and common mistakes.
Explanation
The Terraform Associate exam dedicates significant weight to state management operations because understanding state is what separates someone who can write HCL from someone who can operate Terraform in production. You must know that terraform state list shows all resource addresses in the current state, terraform state show displays the full attributes of a specific resource, terraform state mv renames a resource address without destroying it (the predecessor to moved blocks), and terraform state rm removes a resource from state without destroying it in the cloud — useful when you want Terraform to stop managing a resource without deleting it. The terraform state pull command downloads the current state as JSON to stdout, which you can pipe to jq for scripting and automation. The terraform state push command uploads a local state file to the configured backend, and is used during state recovery operations. Know that state mv accepts both resource addresses and module paths, and that rm does not prompt for confirmation so it is dangerous in production without proper access controls. The exam will present scenarios where you need to pick the correct state command for a given operational situation.
Command / YAML
# List all resources in the current state terraform state list # Output: aws_vpc.main, aws_subnet.private[0], module.eks.aws_eks_cluster.prod # Show full attributes of a specific resource terraform state show aws_vpc.main # Output: id, cidr_block, tags, etc. # Move/rename a resource in state (no destroy/create) terraform state mv aws_instance.old_name aws_instance.new_name # Remove a resource from state (Terraform forgets it, resource still exists in AWS) terraform state rm aws_s3_bucket.legacy_bucket # Download current state as JSON for inspection terraform state pull | jq '.resources | length' # Push a local state file to the backend (use for recovery) terraform state push -force recovered_state.json # Move a resource into a module terraform state mv aws_security_group.web module.networking.aws_security_group.web
Common Mistake
Candidates confuse terraform state rm with terraform destroy. The state rm command only removes the resource from Terraform's tracking — the actual cloud resource continues to exist. Using destroy when you meant rm will delete the infrastructure, while using rm when you meant to just rename will leave Terraform unable to manage the resource until you import it back.
💬 Comments
Explanation
The exam tests your knowledge of where Terraform can load modules from because module sourcing determines your organization's supply chain security, versioning strategy, and CI performance. Local modules use relative paths like ./modules/vpc and are resolved from the filesystem without any network calls — they are versioned implicitly through your Git repository. The Terraform Registry uses the shorthand format namespace/name/provider (for example, hashicorp/consul/aws) and supports version constraints in the module block. GitHub sources use the format github.com/org/repo followed by an optional //subdirectory path and ?ref=tag for version pinning. Generic Git repositories use the git:: prefix with any valid Git URL. S3 uses the s3:: prefix with a bucket path for private module storage. GCS uses the gcs:: prefix similarly. HTTP URLs work for zip or tar.gz archives. The exam will present scenarios asking you to identify valid source strings, determine which sources support version constraints (only the Registry does natively), and know that terraform init -upgrade is needed to pull newer versions of previously cached modules. Understanding that the version argument only works with Registry modules is a common exam question — for Git sources you use the ref query parameter instead.
Command / YAML
# Local module — relative path, no version constraint
module "vpc" {
source = "./modules/vpc" # Resolved from filesystem
}
# Terraform Registry — supports version constraints
module "eks" {
source = "terraform-aws-modules/eks/aws" # namespace/name/provider
version = "~> 19.0" # Only works with Registry sources
}
# GitHub source — version pinned via ref tag
module "networking" {
source = "github.com/acme-corp/terraform-modules//networking?ref=v2.3.1"
}
# Generic Git with SSH — ref pins the version
module "database" {
source = "git::ssh://[email protected]/acme/tf-modules.git//rds?ref=v1.8.0"
}
# S3 bucket source — for private enterprise modules
module "compliance" {
source = "s3::https://acme-tf-modules.s3.us-east-1.amazonaws.com/compliance/v3.2.0.zip"
}
# GCS bucket source
module "gcp_network" {
source = "gcs::https://www.googleapis.com/storage/v1/acme-tf-modules/network/v1.0.0.zip"
}Common Mistake
Candidates try to use the version argument with GitHub or Git module sources, which is invalid. The version argument only works with Terraform Registry modules. For Git-based sources, you must use ?ref=v1.2.3 appended to the source URL to pin to a specific tag, branch, or commit hash.
💬 Comments
Explanation
Terraform evaluates variables from multiple sources and applies a strict precedence order where later sources override earlier ones. This is a guaranteed exam topic because understanding precedence is essential for debugging 'why did Terraform use this value instead of the one I set?' scenarios in production. The order from lowest to highest priority is: (1) default value in the variable block, (2) environment variables prefixed with TF_VAR_ (for example, TF_VAR_region=us-west-2), (3) terraform.tfvars file loaded automatically if present, (4) any .auto.tfvars files loaded alphabetically, (5) -var-file flag on the command line in the order specified, and (6) -var flags on the command line which have the highest precedence. The exam will present scenarios where the same variable is set in multiple places and ask which value Terraform will use. Key insight: if you set instance_type in terraform.tfvars but also pass -var="instance_type=t3.large" on the command line, the command-line value wins. Environment variables are commonly used in CI pipelines to inject secrets without writing them to files. The terraform.tfvars file is the standard team-shared defaults file. The -var flag is the escape hatch for one-off overrides.
Command / YAML
# Precedence order (lowest to highest):
# 1. Variable default
variable "instance_type" {
default = "t3.micro" # Lowest priority — used only if nothing else sets it
}
# 2. Environment variable (TF_VAR_ prefix)
export TF_VAR_instance_type="t3.small" # Overrides default
# 3. terraform.tfvars (auto-loaded if present)
# terraform.tfvars:
instance_type = "t3.medium" # Overrides env var
# 4. *.auto.tfvars (auto-loaded, alphabetical order)
# production.auto.tfvars:
instance_type = "t3.large" # Overrides terraform.tfvars
# 5. -var-file flag (command line, order matters)
terraform plan -var-file="overrides.tfvars" # Overrides auto.tfvars
# 6. -var flag (HIGHEST priority)
terraform plan -var="instance_type=t3.xlarge" # Overrides everything
# Final result: t3.xlarge (command-line -var always wins)Common Mistake
Candidates assume that terraform.tfvars has higher priority than .auto.tfvars files, or they forget that multiple -var-file flags are processed in order with later files winning. Another common error is forgetting the TF_VAR_ prefix for environment variables — setting INSTANCE_TYPE=t3.large does nothing; it must be TF_VAR_instance_type.
💬 Comments
Explanation
Provisioners (local-exec, remote-exec, and file) execute arbitrary commands or scripts on local or remote machines as part of resource creation or destruction. The exam heavily tests whether you understand that HashiCorp explicitly recommends against using provisioners and considers them a last resort. The reasons: provisioners break Terraform's declarative model because their actions are not tracked in state, they cannot be planned or previewed, they are not idempotent by default (running apply twice may execute the script twice with different results), and they create hidden dependencies on network connectivity and authentication that make configurations fragile. The recommended alternatives are: use cloud-init or user_data for EC2 instance bootstrapping, use configuration management tools like Ansible for post-provisioning configuration, use Packer to bake AMIs with pre-installed software, and use the terraform_data resource (replacing null_resource in 1.7+) with triggers for lifecycle-driven scripts. If you must use a provisioner, know that on_failure can be set to continue or fail, that destroy-time provisioners run before the resource is destroyed, and that connection blocks define how remote-exec connects to the target. The exam will present scenarios asking you to identify the most appropriate approach — and the answer is almost never provisioners.
Command / YAML
# ANTI-PATTERN: Using provisioners (last resort only)
resource "aws_instance" "app" {
ami = "ami-0abc123def456789a"
instance_type = "t3.medium"
# remote-exec — runs commands over SSH on the remote instance
provisioner "remote-exec" {
inline = [
"sudo yum install -y nginx",
"sudo systemctl enable nginx"
]
connection {
type = "ssh"
user = "ec2-user"
private_key = file("~/.ssh/id_rsa")
host = self.public_ip
}
}
# local-exec — runs commands on the machine running Terraform
provisioner "local-exec" {
command = "echo ${self.private_ip} >> inventory.txt"
}
# Destroy-time provisioner — runs before resource is destroyed
provisioner "local-exec" {
when = destroy
command = "./scripts/deregister-from-consul.sh ${self.private_ip}"
}
}
# RECOMMENDED: Use user_data instead of provisioners
resource "aws_instance" "app_better" {
ami = "ami-0abc123def456789a"
instance_type = "t3.medium"
user_data = file("${path.module}/scripts/bootstrap.sh") # Cloud-init
}Common Mistake
Candidates choose provisioners as the answer when the question asks about installing software on EC2 instances. The correct answer for the exam is almost always user_data/cloud-init or pre-baked AMIs via Packer. Provisioners are only correct when the question explicitly says 'last resort' or asks what provisioners do, not when they should be used.
💬 Comments
Explanation
Terraform workspaces provide a mechanism to maintain multiple distinct state files for the same configuration, enabling environment separation without duplicating code. The exam tests your understanding that each workspace has its own independent state file stored at a separate key path in the backend — the default workspace stores state at the configured key, while named workspaces store state at env:/workspace-name/key. Know that terraform workspace new creates a workspace and switches to it, terraform workspace select switches between existing workspaces, terraform workspace list shows all workspaces with the current one starred, and terraform workspace show prints just the current workspace name. The terraform.workspace expression in HCL returns the current workspace name, allowing conditional logic for environment-specific values. Critical exam knowledge: workspaces share the same configuration files and the same backend configuration — they only separate state. This means workspaces are suitable for minor environment variations but not for fundamentally different infrastructure layouts. HashiCorp recommends separate root modules or Terraform Cloud workspaces for true environment isolation. The default workspace cannot be deleted, and Terraform always starts in the default workspace. State files for non-default workspaces are stored in a different S3 key path than the configured key.
Command / YAML
# Create a new workspace
terraform workspace new staging
# Output: Created and switched to workspace "staging"!
# List all workspaces (current marked with *)
terraform workspace list
# Output:
# default
# * staging
# production
# Switch to an existing workspace
terraform workspace select production
# Show current workspace name
terraform workspace show
# Output: production
# Use workspace name in configuration for environment logic
locals {
instance_type = terraform.workspace == "production" ? "c6i.2xlarge" : "t3.small"
env_prefix = terraform.workspace # Used in resource naming
}
resource "aws_instance" "app" {
ami = data.aws_ami.app.id
instance_type = local.instance_type
tags = {
Name = "${local.env_prefix}-payments-app"
Environment = terraform.workspace
}
}
# State storage paths in S3:
# default: s3://bucket/infrastructure/terraform.tfstate
# staging: s3://bucket/env:/staging/infrastructure/terraform.tfstate
# production: s3://bucket/env:/production/infrastructure/terraform.tfstateCommon Mistake
Candidates believe that workspaces provide complete isolation between environments including separate configurations and separate backends. In reality, workspaces share the same configuration and backend — they only provide separate state files. The exam question often contrasts workspaces with Terraform Cloud workspaces, which do provide full isolation.
💬 Comments
Explanation
Backends determine where Terraform stores its state and how operations are executed. The exam tests your knowledge of backend types, their capabilities, and configuration patterns. The local backend stores state on disk and is the default — it provides no locking, no remote access, and no team collaboration. The S3 backend stores state in an S3 bucket and uses a DynamoDB table for locking — it is the most common production backend for AWS users. The azurerm backend uses Azure Blob Storage with built-in locking via blob leases. The consul backend stores state in Consul's KV store with built-in locking. The Terraform Cloud/Enterprise backend (cloud block in HCL) stores state remotely and can also execute operations remotely (remote execution mode). Know that backend configuration cannot use variables or expressions — all values must be literals or come from partial configuration files loaded via -backend-config flag during init. The terraform init command initializes the backend and is the only command that creates the .terraform directory. Changing backends requires running terraform init -migrate-state to transfer state between backends. The -reconfigure flag reinitializes without migrating state. Partial configuration allows injecting sensitive backend values (like access keys) at runtime through files or CLI flags rather than hardcoding them.
Command / YAML
# S3 backend with DynamoDB locking (most common for AWS)
terraform {
backend "s3" {
bucket = "acme-terraform-state"
key = "prod/payments/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-locks" # Provides state locking
}
}
# Terraform Cloud backend (cloud block — newer syntax)
terraform {
cloud {
organization = "acme-corp"
workspaces {
name = "payments-production"
}
}
}
# Partial configuration — inject secrets at runtime
# backend.hcl (not committed to Git):
# access_key = "AKIA..."
# secret_key = "..."
terraform init -backend-config=backend.hcl
# Migrate state between backends
terraform init -migrate-state
# Reinitialize without migrating (loses state reference)
terraform init -reconfigure
# Backend config CANNOT use variables:
# backend "s3" { bucket = var.bucket } ← INVALID! Will not work.Common Mistake
Candidates try to use variables or locals in backend configuration blocks, which is not supported. Backend configuration must be static literals or injected via -backend-config at init time. Another common mistake is forgetting that terraform init -reconfigure does NOT migrate state — it starts fresh, potentially losing the state reference.
💬 Comments
Explanation
The exam heavily tests the difference between count and for_each because they solve different problems and have different implications for resource identity and lifecycle. Count creates resources indexed by integer (0, 1, 2...) and is best for creating N identical copies of a resource. For_each creates resources indexed by string keys from a map or set and is best for creating resources with distinct identities. The critical difference is what happens when you remove an item from the middle: with count, removing index 1 from a list of 3 causes index 2 to shift down to index 1, triggering a destroy and recreate of that resource because its index changed. With for_each, removing a key only affects that specific resource — other resources are untouched because they are identified by their key string, not their position. Know that count resources are addressed as resource.name[0], resource.name[1] etc., while for_each resources are addressed as resource.name["key"]. Count cannot be used with for_each on the same resource. For_each requires a set or map — to convert a list to a set, use toset(). Count can be used conditionally: count = var.create_resource ? 1 : 0 is a common pattern for optional resources. For_each.value gives the current item's value, and for_each.key gives the current key.
Command / YAML
# COUNT — creates N identical resources, indexed by integer
resource "aws_instance" "web" {
count = 3 # Creates web[0], web[1], web[2]
ami = "ami-0abc123def456789a"
instance_type = "t3.medium"
tags = {
Name = "web-server-${count.index}" # count.index: 0, 1, 2
}
}
# Addresses: aws_instance.web[0], aws_instance.web[1], aws_instance.web[2]
# FOR_EACH with map — creates resources indexed by string key
resource "aws_instance" "service" {
for_each = {
payments = { type = "c6i.2xlarge", az = "us-east-1a" }
orders = { type = "c6i.xlarge", az = "us-east-1b" }
users = { type = "t3.large", az = "us-east-1c" }
}
ami = "ami-0abc123def456789a"
instance_type = each.value.type # each.value accesses map value
availability_zone = each.value.az
tags = {
Name = "${each.key}-service-prod" # each.key: payments, orders, users
Service = each.key
}
}
# Addresses: aws_instance.service["payments"], aws_instance.service["orders"]
# CONDITIONAL resource using count
resource "aws_cloudwatch_metric_alarm" "high_cpu" {
count = var.environment == "production" ? 1 : 0 # Only in prod
# ... alarm config
}
# Converting list to set for for_each
resource "aws_iam_user" "developers" {
for_each = toset(["alice", "bob", "carol"]) # toset() required for lists
name = each.key # For sets, each.key == each.value
}Common Mistake
Candidates use count with a list of distinct items (like subnet CIDRs) and then struggle when removing an item from the middle causes subsequent resources to be destroyed and recreated due to index shifting. The correct approach for distinct items is for_each with a map or set, where each resource has a stable string key independent of position.
💬 Comments
Explanation
Lifecycle meta-arguments control how Terraform handles resource creation, updates, and deletion. The exam tests all four lifecycle rules and when to apply each. prevent_destroy = true causes Terraform to error and refuse any plan that would destroy the resource — use this on critical production resources like databases and S3 buckets. create_before_destroy = true creates the replacement resource before destroying the old one, which is essential for zero-downtime updates to resources like launch configurations, SSL certificates, and security groups that other resources reference. ignore_changes takes a list of attribute names that Terraform will not track after creation — useful when external systems (like autoscaling or manual emergency fixes) modify attributes that should not trigger drift detection. replace_triggered_by (added in Terraform 1.2) forces a resource to be replaced when a referenced resource or attribute changes, even if nothing in the resource's own configuration changed — useful for rotating secrets or forcing AMI updates. Know that precondition and postcondition blocks (added in 1.2) are also part of lifecycle management, allowing you to assert conditions before or after resource operations. The exam will present scenarios asking which lifecycle rule prevents accidental deletion, which enables blue-green deployments, and which suppresses false drift.
Command / YAML
resource "aws_db_instance" "payments" {
identifier = "prod-payments-db"
engine = "postgres"
engine_version = "15.4"
instance_class = "db.r6g.4xlarge"
lifecycle {
# Prevents accidental terraform destroy from deleting this database
prevent_destroy = true
# Ignore changes made outside Terraform (e.g., manual scaling)
ignore_changes = [
instance_class, # DBA may scale up during incidents
engine_version, # Managed by AWS auto-minor-upgrade
]
}
}
resource "aws_launch_template" "app" {
name_prefix = "payments-app-"
image_id = data.aws_ami.app.id
instance_type = "c6i.2xlarge"
lifecycle {
# Create new launch template before destroying old one
# Prevents ASG from having no valid template during update
create_before_destroy = true
}
}
resource "aws_instance" "worker" {
ami = data.aws_ami.worker.id
instance_type = "c6i.xlarge"
lifecycle {
# Force replacement when the secret version changes
replace_triggered_by = [
aws_secretsmanager_secret_version.db_password.id
]
}
}
# Postcondition — validate after creation
resource "aws_instance" "critical" {
ami = data.aws_ami.app.id
instance_type = "c6i.2xlarge"
lifecycle {
postcondition {
condition = self.public_ip != ""
error_message = "Instance must have a public IP assigned."
}
}
}Common Mistake
Candidates think prevent_destroy prevents the resource from ever being destroyed by any means. In reality, it only prevents Terraform from planning a destroy — you can still remove the lifecycle block and then destroy, or use terraform state rm followed by manual deletion. It is a safety net against accidental terraform destroy, not an absolute protection.
💬 Comments