HCL refactoring patterns, state migration walkthroughs, and module-design teardowns. Reinforce the count vs for_each decision, S3 + DynamoDB locking, and the new moved / import / removed blocks while commuting.
About the exam
Why earn the Terraform Associate?
Terraform 003 is the most-recognized IaC certification on the market. It maps cleanly to real DevOps and platform-engineering work, costs almost nothing to take, and signals to hiring managers that you can ship multi-cloud infrastructure with a code-review workflow.
- Vendor-neutral IaC credential — applies to every cloud and on-prem stack (AWS, Azure, GCP, vSphere, Kubernetes, GitHub, Datadog, …)
- Cheap exam ($70.50) and short prep window (3-6 weeks for cloud engineers already shipping infra)
- Validates the de-facto IaC tool used at 70%+ of cloud-mature shops — Terraform is the platform-engineering default
- Gateway to platform engineering, SRE, DevOps, and FinOps roles — every modern platform team needs Terraform fluency
- Salary lift: EU ~€5-12k median, US ~$8-15k for Terraform-fluent engineers vs. console-only operators
- Recognized after the IBM acquisition and the OpenTofu fork — both validate the standard, neither breaks your existing skills
count vs for_each cold (count = list indices that re-key on removal, for_each = map keys / set members that stay stable). Know the state-lock + remote-backend mechanics — the canonical pattern is S3 backend + DynamoDB for locks. Don't memorize HCL function lists; practice them live with terraform console. Expect ~12-15 questions on state operations and modules combined — those two are the heaviest scoring zones.
Exam blueprint
Terraform 003 exam objectives
Nine official objectives. Workflow + state are the heaviest — pair them with modules and you cover ~60% of the exam. The blueprint is officially "informational weight only" — questions are distributed across all nine objectives.
Course content
9 modules · ~25 hours
Each module maps to one or more exam objectives. Work through them in order or use the practice test to find your weak areas and skip ahead. Modules 6 and 7 (state + modules) are the heaviest — budget extra time there.
Infrastructure as Code Concepts3 lessons
The conceptual foundation. Declarative vs imperative, idempotency, the value of version control for infrastructure, and how Terraform compares to CloudFormation, Pulumi, Ansible, and Crossplane. Lightweight on the exam (objectives 1 + 2 are ~30% each at the question-pool sample size) but every later module assumes you've internalized the model.
📖 Read in-depth chapter ▾
IaC means describing infrastructure in text files that live in Git, then having a tool reconcile reality to match. The exam tests the conceptual fundamentals — declarative vs imperative, idempotency, and why version control changes operations.
- Declarative vs Imperative: Declarative IaC (like Terraform) describes the desired end state and the tool figures out how to achieve it. Imperative IaC (like scripting with AWS CLI or Bash) specifies the exact step-by-step commands to execute. Terraform's declarative model is simpler to maintain because you describe "what" not "how".
- Idempotency: Running the same Terraform configuration multiple times produces the same result. If the infrastructure already matches the desired state, Terraform makes no changes. This prevents configuration drift and makes re-runs safe.
- Version Control: IaC files are stored in Git repositories, enabling pull request reviews, change history, rollback capabilities, and collaborative workflows. Every infrastructure change is tracked, auditable, and reproducible.
- Key IaC Benefits: Eliminates manual, error-prone provisioning. Enables consistent environments across dev, staging, and production. Supports rapid disaster recovery by re-provisioning from code. Reduces time-to-deploy from hours to minutes.
- IaC Tools Landscape: Terraform (multi-cloud, declarative, HCL), AWS CloudFormation (AWS-only, JSON/YAML), Azure Resource Manager (Azure-only), Pulumi (multi-cloud, general-purpose languages), Ansible (configuration management, procedural). Terraform stands out for its cloud-agnostic provider model.
A team manages 200 EC2 instances by hand: ticket → engineer logs in → click through console → close ticket. Drift everywhere, no audit trail. Rewrite as Terraform: a single resource "aws_instance" "web" with count = 200 in a Git repo. PRs review every change, terraform plan shows the diff before merge, terraform apply reconciles state. Re-running apply on an unchanged config is a no-op — that's idempotency. The team's MTTR for "rebuild the env" drops from days to minutes.
Knowing the benefits in interview-quotable form matters for both the exam and the job. Terraform's three differentiators — execution plan, resource graph, provider ecosystem — appear in nearly every "why Terraform" question.
- Consistency & Reproducibility: Terraform ensures every environment is provisioned identically from the same configuration files. No more "works on my machine" problems — dev, staging, and production are created from the same code.
- Collaboration & Review: Infrastructure changes go through the same code review process as application code. Teams use pull requests to propose changes, run
terraform planin CI pipelines, and approve before applying. - Terraform's Provider Ecosystem: Terraform uses providers as plugins to interact with cloud platforms, SaaS tools, and other APIs. The Terraform Registry hosts thousands of providers and modules, making it the most extensible IaC tool available.
- State as Source of Truth: Terraform maintains a state file that maps your configuration to real-world resources. This allows Terraform to detect drift, plan incremental changes, and destroy resources cleanly when they are removed from code.
- Execution Plans: Before making any change,
terraform planshows exactly what will be created, modified, or destroyed. This safety net prevents surprises and gives teams confidence before applying changes to production.
A platform team needs to add a new env. With Ansible-only: rewrite playbooks, hand-create cloud resources, hope nothing drifts. With Terraform: copy envs/prod to envs/staging, change a .tfvars file (CIDR + instance counts), terraform apply. The resource graph parallelizes resource creation by dependency order, so a 50-resource env spins up in minutes. terraform plan in CI catches a typo before it reaches the cloud. Terraform handles provisioning; Ansible takes over for in-VM config management — the two compose.
The exam tests whether you can place Terraform correctly in the IaC tool landscape. Each tool has a different stance on declarative vs imperative, cloud reach, state, and execution model. Knowing the contrasts answers most "which tool fits this scenario" questions.
- Terraform: declarative, multi-cloud, HCL-based, with an explicit state file and a plan/apply lifecycle. Open-source binary, MPL-2.0 → BSL relicense (2023) → OpenTofu fork (2024). Strongest provider ecosystem in the industry.
- Pulumi: declarative model but written in general-purpose languages (TypeScript, Python, Go, .NET). State file too (managed service or self-hosted). Wins on "use real-language tests + loops"; loses on "single-file readability" and the broader Terraform community.
- CloudFormation: AWS-only, declarative, JSON/YAML. State is implicit (managed by AWS in the stack). Strong drift detection + change sets. Ties you to AWS but integrates deeply (e.g., StackSets, native rollback). Right answer when the prompt says "single-cloud AWS shop, no third-party".
- Ansible: imperative-leaning configuration management — runs playbooks against existing machines to install / configure software. No state file by default. Complementary to Terraform: Terraform provisions the VM, Ansible configures it.
- Crossplane: Kubernetes-native IaC. Resources expressed as Kubernetes CRDs reconciled by an operator. Fits orgs that already run k8s as a control plane. State lives in etcd. Different operational model from Terraform's plan/apply.
- OpenTofu: open-source fork of Terraform (MPL-2.0) created after the BSL relicense. Drop-in compatible with the Terraform CLI today; diverges slowly. Exam still tests Terraform — but knowing OpenTofu exists is useful career context.
A multi-cloud SaaS runs production on AWS + Cloudflare + Datadog. Tool choice: Terraform wins — one tool reconciles all three providers in the same plan. If the same shop were AWS-only and didn't need multi-provider, CloudFormation would be a defensible fallback (deeper AWS integration). If the org's platform layer is already Kubernetes with operators everywhere, Crossplane fits the existing reconciliation model. For software inside the resulting VMs, the team layers Ansible on top of Terraform-provisioned hosts.
Terraform Basics3 lessons
Install the binary, understand HCL syntax (blocks, arguments, expressions, heredocs, data types), and master the core CLI lifecycle: terraform init, terraform plan, terraform apply, terraform destroy, plus fmt and validate. Every later module assumes this is muscle memory.
📖 Read in-depth chapter ▾
The Terraform binary is dependency-free, but the file-layout conventions and the terraform {} settings block are essential exam knowledge. Get the install and the first terraform init right and the rest follows.
- Installation: Terraform is distributed as a single binary with no dependencies. Download it from releases.hashicorp.com or install via package managers (apt, yum, brew, choco). Verify with
terraform version. Multiple versions can be managed with tools like tfenv. - Provider Authentication: Terraform authenticates to cloud providers using credentials configured outside of Terraform code. For AWS, use environment variables (
AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY), shared credentials file (~/.aws/credentials), or IAM instance profiles. Never hardcode credentials in.tffiles. - Terraform Settings Block: The
terraform {}block configures Terraform itself:required_versionconstrains the CLI version,required_providersdeclares providers and their version constraints, andbackendconfigures where state is stored. - File Structure: Terraform loads all
.tffiles in the working directory. Common convention:main.tf(resources),variables.tf(input variables),outputs.tf(output values),providers.tf(provider config),terraform.tfvars(variable values). - .terraform Directory: Created by
terraform init, this hidden directory stores downloaded provider plugins and module source code. It should be added to.gitignoreas it contains binaries and can be regenerated at any time.
A new repo gets the canonical layout: main.tf, variables.tf, outputs.tf, versions.tf (with the terraform {} block pinning required_version = "~> 1.7" and required_providers with hashicorp/aws ~> 5.0). .gitignore excludes .terraform/, *.tfstate*, and *.tfvars. Credentials come from AWS_ACCESS_KEY_ID env vars in CI and from ~/.aws/credentials on a dev laptop. terraform init downloads the AWS provider into .terraform/providers/ and writes .terraform.lock.hcl (which IS committed).
terraform init always runs first. Commit .terraform.lock.hcl; never commit .terraform/, *.tfstate*, or *.tfvars containing secrets. Credentials go in env vars or shared-creds files, not in .tf.
Every exam question that contains code is in HCL. Reading blocks, labels, arguments, and expression syntax at a glance is the single most leveraged skill on the test.
- Block Structure: HCL (HashiCorp Configuration Language) uses blocks as the fundamental unit:
block_type "label1" "label2" { ... }. Resource blocks have two labels (type and name):resource "aws_instance" "web" { ... }. Arguments inside blocks assign values:ami = "ami-0c55b159cbfafe1f0". - Data Types: Primitive types include
string,number, andbool. Complex types includelist(type)(ordered collection),map(type)(key-value pairs),set(type)(unordered unique values),object({...})(named attributes), andtuple([...])(ordered mixed types). - Expressions: References use dot notation:
aws_instance.web.idaccesses an attribute. String interpolation:"Hello, ${var.name}". Conditional:condition ? true_val : false_val. For expressions:[for s in var.list : upper(s)]. - Comments: Single-line comments use
#or//. Multi-line comments use/* ... */. Comments are essential for documenting complex configurations and explaining non-obvious design decisions. - Heredoc Syntax: Multi-line strings use
<<EOF ... EOFor indented heredocs<<-EOF ... EOF(which strips leading whitespace). Useful for inline policies, user data scripts, and multi-line configuration values.
Read this carefully: resource "aws_security_group" "web" { name = "web-sg" ingress { from_port = 443 to_port = 443 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] } }. Block type = resource, labels = "aws_security_group" + "web" (type + local name), arguments = name, nested block = ingress with its own arguments. Inside another resource: vpc_security_group_ids = [aws_security_group.web.id] — that's a reference expression creating an implicit dependency.
= assigns arguments, {} opens nested blocks. Terraform reads every .tf file in the directory as one configuration — block order across files doesn't matter because dependencies are graph-derived.
The core workflow is objective 6 — the single heaviest scoring zone of the exam. Internalize what each command does, what flags matter, and what the symbols in plan output mean.
- terraform init: Initializes the working directory by downloading provider plugins, installing modules, and configuring the backend. Must be re-run when providers, modules, or backend configuration change. Use
-upgradeto update provider versions within constraints. - terraform plan: Creates an execution plan showing what Terraform will do without making any changes. Output uses
+for create,-for destroy,~for update in-place, and-/+for destroy and recreate. Save plans with-out=plan.tfplanfor safe apply. - terraform apply: Executes the planned changes to reach the desired state. Without a saved plan, it runs an implicit plan and prompts for confirmation. Use
-auto-approveto skip confirmation in CI/CD pipelines (with caution). After apply, the state file is updated. - terraform destroy: Removes all resources managed by the configuration. Equivalent to removing all resources from code and running apply. Prompts for confirmation. Use
-targetto destroy specific resources, though this is discouraged for routine use. - terraform validate & fmt:
validatechecks configuration syntax and internal consistency without accessing providers.fmtrewrites files to the canonical HCL format. Both are commonly run in CI pipelines as pre-merge checks.
A production PR pipeline: on push, CI runs terraform fmt -check (fails the build on bad formatting), terraform validate (syntax + type check, offline), then terraform plan -out=tfplan. The plan output is posted as a PR comment; reviewers see + aws_instance.web and ~ aws_security_group.web. After merge, the main-branch job runs terraform apply tfplan with the exact plan reviewed — no drift between review and execution. terraform destroy is reserved for tear-down of throwaway envs.
Halfway through the basics? Reinforce HCL syntax and the plan/apply lifecycle by listening to the CertQuests podcast — concise audio breakdowns of exactly these scenarios for your commute.
▶ Open SpotifyProviders3 lessons
Providers are how Terraform talks to AWS, Azure, GCP, GitHub, Datadog, and everything else. Master version constraints, the .terraform.lock.hcl file, provider aliases for multi-region deployments, and the canonical authentication patterns including OIDC for short-lived CI credentials.
📖 Read in-depth chapter ▾
Version constraints are one of the most-tested objective 3 topics. Memorize the operators — especially the pessimistic ~> — and the role of .terraform.lock.hcl for reproducible installs.
- What Are Providers: Providers are plugins that let Terraform interact with APIs of cloud platforms (AWS, Azure, GCP), SaaS services (GitHub, Datadog), and other tools (Kubernetes, Helm). Each provider adds resource types and data sources specific to that service.
- Provider Block: Configured with
provider "aws" { region = "us-east-1" }. The provider block sets connection parameters like region, credentials, and endpoint URLs. Provider configuration is separate from resource definitions. - Version Constraints: Declared in the
required_providersblock using operators:=(exact),>=(minimum),~>(pessimistic, allows only rightmost version increment). Example:version = "~> 5.0"allows 5.x but not 6.0. The.terraform.lock.hclfile pins exact versions after init. - Dependency Lock File:
.terraform.lock.hclrecords the exact provider versions and hashes selected duringterraform init. This file should be committed to version control to ensure all team members and CI pipelines use identical provider versions. - Provider Registry: The Terraform Registry (registry.terraform.io) is the default source for providers. Provider addresses follow the format
namespace/type(e.g.,hashicorp/aws). Custom or private registries can be configured for internal providers.
In versions.tf: terraform { required_version = "~> 1.7" required_providers { aws = { source = "hashicorp/aws" version = "~> 5.30" } } }. After terraform init the lock file pins to e.g. 5.34.0. A junior dev runs terraform init -upgrade six months later — the constraint allows 5.x, lock updates to 5.42.0, no 6.0 surprise. If the constraint were ~> 5 instead, 5.42 would still resolve but 6.0 would NOT (the rightmost increment in ~> X.Y is the last position).
~> 5.0 = allow 5.x, block 6.0. ~> 5.1.0 = allow 5.1.x, block 5.2.0. Always commit .terraform.lock.hcl; never commit .terraform/.
Multi-region deployments — and module-passed providers — depend on aliases. The exam pattern is unmistakable: two provider "aws" blocks, "which resources go where".
- Provider Aliases: When you need multiple configurations of the same provider (e.g., deploying to two AWS regions), define additional instances with the
aliasargument:provider "aws" { alias = "west" region = "us-west-2" }. Resources reference aliases withprovider = aws.west. - Default vs Aliased Providers: A provider block without an alias is the default for that provider type. Resources use the default provider unless they explicitly reference an alias. Only one default provider per type is allowed.
- Multi-Region Deployments: Common pattern: define a default provider for the primary region and aliased providers for secondary regions. Resources like DR replicas or CloudFront origins reference the aliased provider for the target region.
- Passing Providers to Modules: When a module needs a non-default provider, pass it via the
providersmeta-argument in the module block:module "west_vpc" { providers = { aws = aws.west } }. This keeps modules flexible and reusable across regions. - Multi-Cloud Configurations: A single Terraform configuration can use multiple providers (e.g., AWS and Cloudflare) simultaneously. Each provider manages its own set of resources independently, and Terraform handles the dependency graph across all providers.
A DR-ready architecture: default provider "aws" { region = "us-east-1" } + aliased provider "aws" { alias = "dr" region = "us-west-2" }. The primary VPC + RDS use the default provider implicitly. The DR S3 replica targets provider = aws.dr. A reusable module "vpc" instantiated twice — once for prod, once for DR — receives the right provider via module "dr_vpc" { source = "./modules/vpc" providers = { aws = aws.dr } }. The module's internal resources inherit the DR region automatically.
provider argument use the default (non-aliased). Aliases are addressed as aws.alias_name. Pass them to modules with the providers meta-argument.
Authentication is the single highest-risk Terraform topic in real-world use — bad credential hygiene is how secrets leak to Git. The exam tests the canonical patterns: env vars, shared creds, IAM instance profiles, OIDC, and short-lived Vault credentials.
- Environment variables: the most portable. AWS:
AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY, optionalAWS_SESSION_TOKEN. Azure:ARM_CLIENT_ID,ARM_CLIENT_SECRET,ARM_TENANT_ID,ARM_SUBSCRIPTION_ID. GCP:GOOGLE_APPLICATION_CREDENTIALSpath to a JSON key file. The default for CI runners. - Shared credentials files:
~/.aws/credentials(AWS),~/.azure/credentials(Azure),~/.config/gcloud/application_default_credentials.json(GCP). Per-profile sections enabled byAWS_PROFILE/ equivalent. Standard for local dev. - Instance profile / managed identity: when Terraform runs ON an EC2 instance / Azure VM / GCE host, the provider auto-detects the attached IAM role / managed identity. No keys to store. The right answer for self-hosted runners.
- Assume role + STS:
provider "aws" { assume_role { role_arn = "arn:aws:iam::123:role/TerraformDeploy" session_name = "tf" } }. Cross-account access without long-lived keys; the calling identity must havests:AssumeRoleon the target. - OIDC for CI (GitHub Actions, GitLab): the runner exchanges its short-lived OIDC JWT for cloud creds. AWS:
aws-actions/configure-aws-credentials@v4with a trust policy on the IAM role keyed to the repo + branch. No AWS keys in GitHub Secrets — the canonical modern pattern. - Vault provider for short-lived creds: the
vaultprovider plus dynamic-credential engines (AWS, Azure, database) issues per-run credentials that expire after the apply. Never commit.tfvarscontaining secrets — gitignore them and inject viaTF_VAR_*env vars or a Vault data source.
A platform team's GitHub Actions pipeline avoids any long-lived AWS key. Setup: an IAM role arn:aws:iam::123:role/TerraformDeploy with a trust policy keyed to token.actions.githubusercontent.com + the repo + the main branch. The workflow uses aws-actions/configure-aws-credentials@v4 with role-to-assume. GitHub's OIDC issuer mints a short JWT, AWS STS exchanges it for 1-hour creds, the runner exports them as env vars, and terraform apply uses them implicitly. Database passwords come from a vault_generic_secret data source — never committed.
.tfvars with secrets.
Resources & Data Sources3 lessons
Resources create infrastructure; data sources read it. Cover resource block syntax, in-place vs destroy/recreate updates, implicit dependencies via attribute references, lifecycle and timeouts, the four meta-arguments (count, for_each, depends_on, lifecycle), and how to use data sources — including terraform_remote_state — to wire external information into your config.
📖 Read in-depth chapter ▾
The resource block is the atomic unit of Terraform. Understand resource addressing, the implicit dependency graph, and the difference between in-place update and force-replacement.
- Resource Block Syntax:
resource "provider_type" "local_name" { ... }. The type determines the infrastructure object (e.g.,aws_instance,azurerm_resource_group). The local name is an identifier used to reference the resource within the configuration. - Resource Behavior: When you add a resource block and apply, Terraform creates the real infrastructure. When you change arguments, Terraform updates in-place if possible or destroys and recreates if the change is destructive (forces replacement). When you remove a block, Terraform destroys the resource.
- Implicit Dependencies: Terraform automatically detects dependencies when one resource references another's attributes. For example,
subnet_id = aws_subnet.main.idcreates an implicit dependency ensuring the subnet is created before the instance. - Resource Addressing: Every resource has a unique address in the format
provider_type.local_name(e.g.,aws_instance.web). With count, it becomesaws_instance.web[0]. With for_each, it becomesaws_instance.web["key"]. These addresses are used in state commands and-targetflags. - Timeouts: Some resources support custom timeout blocks for create, update, and delete operations. Example:
timeouts { create = "60m" }gives a long-running resource like an RDS instance more time to provision before Terraform considers it failed.
Change resource "aws_instance" "web" { ami = "ami-old" instance_type = "t3.micro" } to a new AMI: terraform plan shows -/+ for the instance — the AMI is a force-replacement attribute. Same resource, change just the tags: plan shows ~, in-place. Reference subnet_id = aws_subnet.main.id — the implicit dependency means aws_subnet.main is created first. Address the instance as aws_instance.web in state commands; with count = 3 it's aws_instance.web[0..2]; with for_each = { a = ..., b = ... } it's aws_instance.web["a"].
+ create, - destroy, ~ update in-place, -/+ force replacement. The provider docs mark which attributes force replacement — know how to spot them in plan output.
Meta-arguments are the most-tested HCL topic on the exam. The single biggest discriminator: count versus for_each. Get the mental model right and you bank several points.
- count: Creates multiple instances of a resource using an integer. Access individual instances with
[index]. Example:count = 3creates three identical resources. Usecount.indexinside the block to differentiate them (e.g., naming). Drawback: removing an item from the middle reindexes all subsequent resources. - for_each: Creates instances from a map or set of strings. Each instance is identified by its key, so adding or removing items does not affect others. Preferred over count for non-identical resources. Access values with
each.keyandeach.value. - depends_on: Explicitly declares a dependency when Terraform cannot detect it automatically (e.g., when a dependency is through a side effect like an IAM policy that must exist before an EC2 instance can use it). Takes a list of resource addresses:
depends_on = [aws_iam_role_policy.example]. - lifecycle Block: Customizes resource behavior.
create_before_destroy = truecreates the replacement before destroying the original (avoids downtime).prevent_destroy = trueblocks any plan that would destroy the resource.ignore_changestells Terraform to ignore external modifications to specified attributes. - count vs for_each: Use count when resources are nearly identical and differ only by index. Use for_each when each instance has a distinct identity (e.g., a map of subnet CIDRs by AZ). for_each is safer because it uses map keys as identifiers rather than sequential indices.
Wrong: resource "aws_instance" "web" { count = length(var.names) tags = { Name = var.names[count.index] } }. Remove the middle name from var.names — Terraform sees the list shifted, every subsequent index changes, and force-replaces N-1 instances. Right: resource "aws_instance" "web" { for_each = toset(var.names) tags = { Name = each.key } }. Remove the same middle name — Terraform destroys only that one instance, the rest are stable. Add lifecycle { create_before_destroy = true } when the resource is behind a load balancer for zero-downtime replacement.
count = list indices (re-key on removal = risky). for_each = string keys (stable). Use for_each by default; reserve count for "create 0 or 1 of this" conditionals.
Data sources let you read existing infrastructure without managing it. They're how you wire pre-existing VPCs, AMIs, IAM policies, and other configs' outputs into your Terraform plan.
- Data Source Purpose: Data sources let Terraform read information from existing infrastructure that is not managed by the current configuration. They are read-only — they query the provider API but never create, update, or delete anything.
- Data Source Syntax:
data "aws_ami" "latest" { most_recent = true filter { ... } }. Referenced asdata.aws_ami.latest.id. Data sources use thedatablock type instead ofresourceand typically include filter arguments to narrow results. - Common Use Cases: Look up the latest AMI ID by filters, retrieve the current AWS account ID or region, query existing VPCs or subnets by tags, read an IAM policy document, or fetch outputs from another Terraform state via
terraform_remote_state. - terraform_remote_state: A special data source that reads output values from another Terraform configuration's state file. Enables cross-project references:
data "terraform_remote_state" "network" { backend = "s3" config = { ... } }. Only exposes output values, not internal resource attributes. - Data Source Dependencies: Data sources participate in Terraform's dependency graph. If a data source references a resource attribute, Terraform reads the data source only after the resource is created. This ensures data sources return current information.
A network-team config owns the VPC and exports output "vpc_id" { value = aws_vpc.main.id } with its state in S3. The app-team config reads it: data "terraform_remote_state" "network" { backend = "s3" config = { bucket = "tf-state-prod" key = "network/terraform.tfstate" region = "us-east-1" } }, then references vpc_id = data.terraform_remote_state.network.outputs.vpc_id. The same config uses data "aws_ami" "ubuntu" { most_recent = true owners = ["099720109477"] filter { name = "name" values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"] } } to always boot the latest Ubuntu LTS.
data., refreshed every plan. terraform_remote_state only exposes declared output values from the source config — internal resource attributes are NOT visible.
Variables & Outputs3 lessons
The configuration interface. Cover variable declarations with types and validation, the six-level precedence chain (defaults → env vars → tfvars → CLI), sensitive variables and outputs, cross-module data flow via module.x.output_name, and locals + built-in functions for derived values.
📖 Read in-depth chapter ▾
Variables are how Terraform configs become reusable. The exam hammers variable precedence and the difference between auto-loaded tfvars and CLI-supplied tfvars.
- Variable Declaration: Defined with
variable "name" { type = string default = "value" description = "..." }. Referenced asvar.namein expressions. Variables without defaults are required and must be provided at runtime. - Variable Precedence (lowest to highest): Default value in the variable block, environment variables (
TF_VAR_name),terraform.tfvarsorterraform.tfvars.json(auto-loaded),*.auto.tfvarsfiles (auto-loaded alphabetically),-var-fileflag,-varflag on the command line. Higher precedence overrides lower. - Type Constraints: Enforce expected types:
string,number,bool,list(string),map(number),object({ name = string, age = number }). Terraform validates types at plan time and returns clear error messages for mismatches. - Validation Rules: Custom validation with
validation { condition = length(var.name) > 0 error_message = "Name cannot be empty." }. Multiple validation blocks are allowed. Conditions must return true for the variable to be accepted. - Sensitive Variables: Mark with
sensitive = trueto prevent the value from appearing in plan output or CLI logs. The value is still stored in state — protect your state file. Sensitivity propagates to any output that references a sensitive variable.
Declared in variables.tf: variable "env" { type = string validation { condition = contains(["dev","staging","prod"], var.env) error_message = "env must be dev/staging/prod." } }. Defaults absent → required. A dev runs terraform plan -var="env=dev" — the -var flag wins over everything else. CI runs terraform plan -var-file=prod.tfvars; terraform.tfvars would be auto-loaded but prod.tfvars is not (no auto suffix). The DB password variable is marked sensitive = true — plan output shows (sensitive value) but the state file still contains the plaintext.
TF_VAR_* env < auto-loaded terraform.tfvars + *.auto.tfvars < -var-file < -var. sensitive = true redacts CLI output only — state still holds plaintext.
Outputs are how a Terraform config or module exposes data to its caller — they're the only interface for cross-module access. Treat them like a function's return values.
- Output Declaration: Defined with
output "name" { value = aws_instance.web.public_ip description = "..." }. Outputs are displayed afterterraform applyand queryable withterraform outputorterraform output -json. - Cross-Module References: Child module outputs are accessed as
module.module_name.output_namein the parent configuration. This is the primary mechanism for passing data between modules — a module's internal resources are not directly accessible from outside. - Sensitive Outputs: Mark with
sensitive = trueto suppress the value in CLI output. Required when the output references a sensitive variable or contains secrets like passwords or API keys. The value is still stored in state. - Output Dependencies: If an output references a resource, Terraform considers that resource a dependency of the output. Outputs with
depends_oncan declare explicit dependencies when the relationship is not visible through attribute references. - terraform output Command: Retrieves output values from state without running apply.
terraform output vpc_idreturns a single value.terraform output -jsonreturns all outputs in JSON format, useful for scripting and integration with other tools.
A VPC module exposes output "vpc_id" { value = aws_vpc.main.id } and output "private_subnet_ids" { value = aws_subnet.private[*].id }. The root module reads them as module.vpc.vpc_id and passes them to an EC2 module: module "app" { source = "./modules/app" subnet_ids = module.vpc.private_subnet_ids }. A script later calls terraform output -json and pipes the result through jq to feed downstream tooling. Internal resources like aws_route_table.private are NOT visible outside the module unless explicitly exposed via an output.
module.x.output_name in the parent; terraform output from the CLI.
Locals and expressions collapse repeated logic into named, reusable values. The exam tests recognition more than memorization — read for-expressions and splat expressions confidently.
- Local Values: Defined with
locals { common_tags = { Environment = var.env, Project = var.project } }. Referenced aslocal.common_tags. Locals act as named expressions that simplify repeated or complex logic, reducing duplication across the configuration. - Built-in Functions: Terraform provides a rich library of functions:
join(",", var.list),lookup(var.map, "key", "default"),length(var.list),merge(map1, map2),file("path"),templatefile("tmpl", vars),cidrsubnet(). Test functions interactively withterraform console. - Conditional Expressions:
condition ? true_val : false_val. Common patterns:count = var.create_resource ? 1 : 0to conditionally create a resource, orinstance_type = var.env == "prod" ? "m5.large" : "t3.micro"to vary configuration by environment. - For Expressions: Transform collections:
[for s in var.list : upper(s)](list),{for k, v in var.map : k => upper(v)}(map). Add filtering withif:[for s in var.list : s if s != ""]. Powerful for deriving complex data structures from simpler inputs. - Splat Expressions: Shorthand for accessing attributes across a list of resources:
aws_instance.web[*].idreturns a list of all instance IDs. Equivalent to[for i in aws_instance.web : i.id]. Works only with list-indexed resources (count), not for_each.
In locals.tf: locals { common_tags = merge(var.base_tags, { Env = var.env, ManagedBy = "terraform" }) instance_type = var.env == "prod" ? "m5.large" : "t3.micro" }. Every resource then sets tags = local.common_tags — single source of truth. To derive subnet CIDRs: cidrs = [for i in range(3) : cidrsubnet(var.vpc_cidr, 4, i)]. Test the expression live: terraform console then type cidrsubnet("10.0.0.0/16", 4, 1) → 10.16.0.0/20.
[*]) summarizes attribute lists from count-indexed resources. Use terraform console to test before pasting.
State Management3 lessons
Objective 7 — implement and maintain state — is the heaviest single objective. Cover what state is, why local state doesn't scale, the canonical S3 + DynamoDB backend, the Azure / GCS / Terraform Cloud equivalents, state migration, and the state-surgery commands list / show / mv / rm / import.
📖 Read in-depth chapter ▾
State is not optional — without it Terraform has no idea what it manages. Understand its purpose, its sensitive contents, and the locking mechanism that prevents two engineers from corrupting each other's applies.
- Purpose of State: Terraform state (
terraform.tfstate) is a JSON file that maps configuration resources to real-world infrastructure objects. It tracks resource IDs, attributes, and metadata so Terraform knows what exists, what needs updating, and what to destroy. - State as Source of Truth: Terraform compares the desired state (your .tf files) against the current state (terraform.tfstate) and the real infrastructure (API calls) to compute the execution plan. Without state, Terraform would have no way to know which resources it manages.
- Sensitive Data in State: State files contain sensitive information in plaintext, including database passwords, API keys, and any attribute values from your resources. State files must be encrypted at rest, access-controlled, and never committed to version control.
- State Locking: When using remote backends, Terraform acquires a lock before any write operation (apply, destroy, import) to prevent concurrent modifications. DynamoDB is used for locking with the S3 backend. If a lock is stuck, use
terraform force-unlock [LOCK_ID]. - terraform refresh: Updates the state file to match the real infrastructure without modifying any resources. As of Terraform 0.15+,
terraform planandterraform applyinclude a refresh step by default, making standaloneterraform refreshrarely necessary.
Two engineers run terraform apply against the same config simultaneously. Without locking, both read state, both compute plans, both write — last writer wins, half the resources are orphaned, state is corrupt. With the S3 backend + DynamoDB lock table, engineer A grabs the lock row, engineer B's apply blocks with Error acquiring the state lock. If A's process dies mid-apply leaving a stale lock, B runs terraform force-unlock 1234abcd after manual verification. Database passwords live in state in plaintext — the S3 bucket has SSE-KMS, versioning, and a deny-all policy except the Terraform IAM role.
terraform.tfstate is for solo dev only. Encrypt state at rest, restrict access, never commit it.
Remote backends are the production answer. The S3 + DynamoDB pattern is exam-canonical for AWS; Azure Blob and Terraform Cloud are the alternatives. Migration between backends is a tested workflow.
- Why Remote Backends: Local state files cannot be shared across a team, do not support locking, and are easily lost. Remote backends store state centrally, enable collaboration, provide locking, and can encrypt state at rest.
- S3 Backend (AWS): The most popular remote backend. Stores state in an S3 bucket with optional server-side encryption (AES-256 or KMS). Pair with a DynamoDB table for state locking and consistency checking. Configure with
backend "s3" { bucket = "..." key = "..." region = "..." dynamodb_table = "..." }. - Azure Blob Storage Backend: Stores state in an Azure Storage Account blob container. Supports locking via Azure Blob leases. Configure with
backend "azurerm" { resource_group_name = "..." storage_account_name = "..." container_name = "..." key = "..." }. - Terraform Cloud Backend: HashiCorp's managed backend offering. Provides state storage, locking, encryption, versioning, access control, and a full history of state changes. Configure with
cloud { organization = "..." workspaces { name = "..." } }. - Backend Migration: Changing backends requires
terraform init -migrate-state, which copies existing state to the new backend. Terraform prompts for confirmation before migrating. Always verify the state after migration withterraform planto ensure no unexpected changes.
Canonical S3 backend in backend.tf: terraform { backend "s3" { bucket = "tf-state-acme-prod" key = "vpc/terraform.tfstate" region = "us-east-1" dynamodb_table = "tf-state-lock" encrypt = true } }. The S3 bucket has versioning on (point-in-time rollback), SSE-KMS for encryption, and Block Public Access. The DynamoDB table uses LockID as the partition key. Migrating from local to S3: drop the backend "s3" block in, run terraform init -migrate-state, Terraform asks "copy existing state? yes". Then terraform plan shows zero diff.
azurerm backend has native blob lease for locking. cloud {} block is the new way to address Terraform Cloud / HCP Terraform — supersedes the legacy remote backend.
State manipulation is objective 7's most surgical territory. The exam expects you to know exactly which command edits state vs. infrastructure, plus the new import {} and moved {} blocks that supersede much of the CLI workflow.
- terraform state list: Lists all resources tracked in the state file. Useful for inventory and verifying that expected resources exist. Supports filtering by address prefix:
terraform state list module.vpc. - terraform state show: Displays the detailed attributes of a single resource in state. Example:
terraform state show aws_instance.webshows the instance ID, AMI, public IP, tags, and all other tracked attributes. Essential for debugging. - terraform state mv: Moves a resource in state without destroying and recreating it. Used for renaming resources (
terraform state mv aws_instance.old aws_instance.new) or moving resources into or out of modules. The actual infrastructure is not affected. - terraform state rm: Removes a resource from state without destroying the real infrastructure. The resource still exists in the cloud but Terraform no longer manages it. Used when transferring ownership to another configuration or manual management.
- terraform import: Brings existing infrastructure under Terraform management by writing a resource entry into state. Requires a corresponding resource block in configuration. Syntax:
terraform import aws_instance.web i-1234567890abcdef0. Does not generate configuration automatically (use import blocks in Terraform 1.5+ for a code-generation workflow).
A team refactors a flat resource into a module. CLI workflow: terraform state mv aws_vpc.main module.network.aws_vpc.main — state addresses update, infrastructure untouched, next plan shows zero diff. To onboard a hand-clicked EC2: write resource "aws_instance" "legacy" { ... } first, then terraform import aws_instance.legacy i-0abc1234. To detach a resource without destroying it: terraform state rm aws_s3_bucket.legacy_data (Terraform forgets it; the bucket lives on). Discover what's tracked: terraform state list | grep vpc.
state mv renames in-state. state rm detaches without destroying. import adopts existing infra (still need the resource block in code). In Terraform 1.5+, the import {} block in config + plan -generate-config-out= automates the resource-block writing.
Modules3 lessons
Modules are how Terraform configs become reusable libraries. Cover module structure, the four source types (local, Registry, Git, S3/GCS/HTTP), input/output interfaces, version pinning with semver, and the composition patterns that keep root modules thin.
📖 Read in-depth chapter ▾
Modules are the answer to "we have the same VPC code copy-pasted in 12 repos". Master the four source types and the canonical file layout.
- What Are Modules: Modules are reusable, self-contained packages of Terraform configuration. Every Terraform configuration is technically a module — the working directory is the "root module." Child modules are called from the root using
moduleblocks. - Module Structure: A minimal module contains
main.tf,variables.tf, andoutputs.tf. Modules should encapsulate related resources (e.g., a VPC module that creates the VPC, subnets, route tables, and NAT gateways together). - Local Modules: Referenced by a relative file path:
module "vpc" { source = "./modules/vpc" }. Changes to local modules take effect immediately on the nextterraform initor plan. Best for organization-specific modules within the same repository. - Registry Modules: Published on registry.terraform.io and referenced by a short address:
module "vpc" { source = "terraform-aws-modules/vpc/aws" version = "5.0.0" }. The Terraform Registry provides documentation, usage examples, and input/output descriptions. - Git & Other Sources: Modules can be sourced from Git repositories (
source = "git::https://github.com/org/repo.git?ref=v1.0"), S3 buckets, GCS buckets, or HTTP URLs. Git sources support branch, tag, and commit references for precise version control.
Root module's main.tf consumes three modules with different sources: module "vpc" { source = "terraform-aws-modules/vpc/aws" version = "~> 5.0" name = "prod" cidr = "10.0.0.0/16" } (Registry — versioned), module "platform" { source = "git::https://github.com/acme/tf-platform.git?ref=v2.3.1" } (private Git tag — pinned), and module "app" { source = "./modules/app" } (local — same repo). terraform init downloads remote modules into .terraform/modules/ and writes their paths into .terraform/modules/modules.json; local modules are read in-place.
version; Git modules need ?ref=; local modules are read from disk. terraform init resolves remote sources; never commit .terraform/modules/.
Modules are functions. Inputs are parameters; outputs are return values; the implementation is private. The exam tests this metaphor directly.
- Module Inputs: Variables declared inside a module act as its input interface. The calling (parent) module passes values as arguments:
module "vpc" { source = "./modules/vpc" cidr_block = "10.0.0.0/16" }. Required variables without defaults must always be provided. - Module Outputs: Outputs declared inside a module expose data to the caller. The parent accesses them as
module.vpc.vpc_id. Only explicitly declared outputs are visible — internal resource attributes are encapsulated within the module. - Module Encapsulation: A module's internal resources, variables, and locals are not directly accessible from outside. This enforces clean interfaces: inputs go in via variables, outputs come out via output blocks. Encapsulation enables safe reuse without understanding implementation details.
- Passing Outputs Between Modules: Common pattern: Module A outputs a VPC ID, and Module B takes it as an input:
module "web" { vpc_id = module.vpc.vpc_id }. Terraform automatically handles the dependency ordering between modules. - Variable Validation in Modules: Module authors should include validation blocks in variables to enforce constraints (e.g., CIDR format, string length, allowed values). This provides clear error messages to module consumers and prevents misconfiguration.
Module ./modules/vpc declares variable "cidr_block" { type = string validation { condition = can(cidrhost(var.cidr_block, 0)) error_message = "Must be a valid CIDR." } } and exposes output "vpc_id" { value = aws_vpc.this.id }. The root calls module "vpc" { source = "./modules/vpc" cidr_block = "10.0.0.0/16" }. A downstream module reads it: module "app" { source = "./modules/app" vpc_id = module.vpc.vpc_id }. Terraform orders the apply so vpc finishes before app starts — implicit dependency through the output reference.
output values are visible to callers. Variable validation blocks give consumers clear error messages. Cross-module dependencies are derived automatically from output references.
Unpinned modules are how production breaks at 2am. Master version constraints (same syntax as providers), semver, and the composition patterns that keep things sane.
- Module Versioning: Registry modules support the
versionargument with the same constraint syntax as providers:version = "~> 5.0". Always pin module versions to prevent unexpected breaking changes. Local modules are versioned through your repository's version control. - Semantic Versioning: Module versions follow semver (MAJOR.MINOR.PATCH). A MAJOR bump may include breaking changes, MINOR adds features backward-compatibly, PATCH fixes bugs. The
~>constraint is ideal for allowing safe updates:~> 5.1allows 5.1.x through 5.x but not 6.0. - Module Composition: Build complex infrastructure by composing smaller modules. A root module might call a VPC module, a security group module, and an EC2 module, wiring their outputs and inputs together. Favor shallow module nesting — deeply nested modules are hard to debug.
- DRY Principle: Do not Repeat Yourself. If you copy-paste the same resource blocks across multiple configurations, extract them into a module. Modules reduce duplication, enforce standards, and make updates easier (change the module once, update everywhere).
- Module Documentation: Every module should include a README with usage examples, a description of each input variable (including type, default, required), and a list of outputs. The Terraform Registry auto-generates documentation from variable and output descriptions.
A platform team publishes terraform-acme/vpc/aws to their private Terraform Cloud registry. Consumers pin: module "vpc" { source = "app.terraform.io/acme/vpc/aws" version = "~> 5.2" cidr = "10.0.0.0/16" }. A breaking change in 6.0.0 (removed input, renamed output) is published — pinned consumers don't break. They upgrade deliberately by bumping to ~> 6.0 after reading the CHANGELOG. Same pattern for Git: source = "git::https://github.com/acme/tf-vpc.git?ref=v5.2.0". Never use unversioned modules in production.
version argument. Git: ?ref= tag. Favor shallow composition; deeply nested modules are debugging hell.
Terraform Cloud & Workspaces3 lessons
Now branded HCP Terraform. Cover CLI workspaces (state-only separation) vs. Terraform Cloud workspaces (full environments), remote runs, Sentinel and OPA policy-as-code, VCS-driven workflows with speculative plans, and the private module registry.
📖 Read in-depth chapter ▾
The same word means two very different things. CLI workspaces are a lightweight state-isolation primitive; Terraform Cloud workspaces are full environments. The exam loves this distinction.
- CLI Workspaces: Terraform CLI supports multiple workspaces (
terraform workspace new dev,terraform workspace select prod) that maintain separate state files within the same configuration. The current workspace is available viaterraform.workspacefor conditional logic. - CLI Workspace State: Each workspace stores its state in a separate file under
terraform.tfstate.d/[workspace_name]/. The default workspace usesterraform.tfstatein the root. CLI workspaces are lightweight but lack access control and audit logging. - Terraform Cloud Workspaces: Fundamentally different from CLI workspaces. Each Terraform Cloud workspace is a full environment with its own state, variables, credentials, run history, and access controls. They are the primary organizational unit in Terraform Cloud.
- Key Differences: CLI workspaces share the same configuration and variables — only state differs. Terraform Cloud workspaces can have different variable values, VCS connections, provider credentials, and team access policies. They are designed for real multi-environment workflows.
- When to Use Which: CLI workspaces are fine for local testing with minor environment differences. Terraform Cloud workspaces are essential for production team workflows where you need access control, run approval, state versioning, and integration with VCS.
A small team uses CLI workspaces for ephemeral feature envs: terraform workspace new feature-1234, terraform apply, demo, terraform workspace delete feature-1234. Inside the config, name = "myapp-${terraform.workspace}" tags resources by workspace. For dev / staging / prod, the same team uses Terraform Cloud workspaces instead — each has its own AWS credentials, different instance_count variable values, distinct team access (devs read prod, only SREs apply), and run history separated by env.
Terraform Cloud (now HCP Terraform) wraps the core CLI with collaboration, governance, and policy enforcement. Objective 9 expects awareness-level knowledge of these features.
- Remote Runs: Terraform Cloud executes
terraform planandapplyin a managed environment, ensuring consistent execution regardless of who triggers the run. Plans can be reviewed and approved before apply, adding a human gate for production changes. - VCS Integration: Connect a workspace to a GitHub, GitLab, or Bitbucket repository. Terraform Cloud automatically triggers a plan on pull requests and applies changes when merged to the default branch. This creates a full GitOps workflow for infrastructure.
- Sentinel Policy as Code: Sentinel is HashiCorp's policy-as-code framework that enforces governance rules. Policies run between plan and apply, blocking non-compliant changes. Examples: require all S3 buckets have encryption enabled, restrict instance types to approved list, mandate tagging on all resources.
- Private Module Registry: Organizations can publish internal modules to Terraform Cloud's private registry, enabling standardized, versioned module sharing across teams. Modules are published from VCS repositories and support the same versioning as the public registry.
- State Management in TFC: Terraform Cloud stores state securely with encryption at rest, versioning (every state change is saved), and access control (only authorized users/teams can read or modify state). State rollback is possible by restoring a previous version.
A prod workspace in TFC is connected to a GitHub repo. A PR opens — TFC runs a speculative plan automatically, posts the diff as a PR check. The Sentinel policy require-encryption-on-s3.sentinel runs between plan and apply; if the plan would create an unencrypted bucket, it blocks the run with a hard-mandatory violation. After merge, the prod workspace triggers an apply that requires an SRE to click "Confirm & Apply". Execution modes available: remote (plan + apply in TFC), local (run on laptop, state in TFC), agent (self-hosted runner for private networks).
The GitOps-style workflow is the canonical use case for Terraform Cloud: PR → speculative plan → policy gate → merge → apply. Combine with the private module registry and you have a real platform-engineering control plane.
- VCS connection: a TFC organization-level setting that authorizes TFC to read repos in GitHub / GitLab / Bitbucket / Azure DevOps. Per-workspace settings then bind a workspace to a specific repo + working directory + branch.
- Speculative plans on PRs: when a PR opens against the workspace's tracked branch, TFC runs
terraform planand posts the result as a PR status check. The plan can NOT be applied — it's read-only feedback for review. Reviewers see what would change before merging. - Sentinel + OPA policy as code: Sentinel is HashiCorp's native PaC; OPA is the CNCF open-source alternative, supported in TFC via the Open Policy Agent integration. Both run between plan and apply. Enforcement: advisory (warn), soft-mandatory (require override), hard-mandatory (block).
- Private module registry: publish internal modules from VCS tags. Consumers reference them as
source = "app.terraform.io/<org>/<module>/<provider>"with aversionconstraint. Same versioning semantics as the public Registry, but access-controlled by org membership. - Run triggers: one workspace's successful apply automatically queues a plan in another workspace. Used for cross-stack pipelines (e.g., apply network module → trigger app workspace plan).
- Module vs Git source: private registry adds versioned discovery + documentation surface; raw Git sources work but provide no UI. Prefer the registry for internally-shared modules; Git sources for one-off / personal modules.
Acme's platform team publishes app.terraform.io/acme/vpc/aws by tagging v1.2.0 on the GitHub source repo — TFC auto-ingests new tags. The prod workspace is VCS-connected to github.com/acme/infra-prod, tracking main. A dev opens a PR adding a new subnet; TFC runs a speculative plan, posts the diff as a check, and runs the Sentinel policy require-tag-environment.sentinel (hard-mandatory). The policy passes; reviewer approves; merge to main triggers an apply, which requires SRE confirmation. Meanwhile a run trigger from the network workspace's apply queues a plan in app automatically.
Advanced Features3 lessons
The last-mile topics that distinguish a junior Terraform user from a fluent one. Provisioners and when to avoid them, dynamic blocks and type constraints, key built-in functions, conditional and splat expressions, and the Terraform 1.5+ refactor blocks: moved {}, import {}, removed {}.
📖 Read in-depth chapter ▾
Provisioners exist; the exam wants you to know why you should almost never use them. They break the declarative model, aren't tracked in state, and create tight coupling between Terraform and runtime configuration.
- What Are Provisioners: Provisioners execute scripts or commands on a local or remote machine as part of resource creation or destruction. They are a bridge between infrastructure provisioning and configuration management, but they break Terraform's declarative model.
- local-exec: Runs a command on the machine where Terraform is executing. Example:
provisioner "local-exec" { command = "echo ${self.private_ip} >> hosts.txt" }. Useful for triggering external scripts, notifying APIs, or generating local files after resource creation. - remote-exec: Connects to the remote resource (via SSH or WinRM) and runs commands directly on it. Requires a
connectionblock with host, user, and authentication details. Used for bootstrapping software or running initial configuration scripts. - Why They Are a Last Resort: Provisioners are not tracked in state, not idempotent by default, and create tight coupling between Terraform and runtime configuration. If a provisioner fails, the resource is marked as tainted. HashiCorp recommends using cloud-init, user data, Packer images, or configuration management tools (Ansible, Chef) instead.
- Creation-Time vs Destroy-Time: Provisioners run at creation by default. Add
when = destroyto run during resource destruction (e.g., deregistering from a load balancer). Destroy-time provisioners must be self-contained — they cannot reference other resources that may already be destroyed.
Wrong: resource "aws_instance" "web" { provisioner "remote-exec" { inline = ["apt-get install -y nginx"] } } — couples Terraform to SSH access, fails if the network ACL blocks port 22 momentarily, and the install isn't tracked in state. Right: use user_data = templatefile("init.sh.tpl", { app_version = var.app_version }) for one-shot bootstrap, or bake Packer AMIs ahead of time and just reference them. If you MUST use a provisioner — say to register a node with a third-party SaaS — keep it small, mark it on_failure = continue if non-critical, and document why you couldn't avoid it.
user_data, Packer-built AMIs, or Ansible run separately. Provisioner failure marks the resource tainted → next apply destroys + recreates it.
Dynamic blocks generate repeated nested blocks; type constraints lock down variable shapes; fmt and validate are the CI guardrails. Common appearance in objective 8 (read / generate / modify config).
- Dynamic Blocks: Generate repeated nested blocks from a collection. Syntax:
dynamic "ingress" { for_each = var.rules content { from_port = ingress.value.from ... } }. Useful for security group rules, tags, or any block that needs to be repeated a variable number of times. Avoid overuse — they reduce readability. - Type Constraints & Structural Types:
object({ name = string, port = number })defines a structured type with named attributes.tuple([string, number])defines an ordered mixed-type list. Theanykeyword allows Terraform to infer the type.optional()marks object attributes as optional with default values in Terraform 1.3+. - terraform fmt: Automatically formats .tf files to the canonical style (consistent indentation, aligned equals signs, sorted arguments). Run
terraform fmt -checkin CI to enforce formatting without modifying files. Non-zero exit code means files need formatting. - terraform validate: Checks configuration for syntax errors, type mismatches, and missing required arguments without accessing any remote state or provider APIs. Faster than plan because it works entirely offline. Use it as a pre-commit hook or CI gate.
- terraform taint & replace:
terraform taint(deprecated) marks a resource for recreation on the next apply. Replaced byterraform apply -replace="aws_instance.web"in Terraform 0.15.2+. Use when a resource is in a bad state and needs to be rebuilt from scratch without changing configuration.
A security group with a variable list of rules: resource "aws_security_group" "web" { dynamic "ingress" { for_each = var.ingress_rules content { from_port = ingress.value.from_port to_port = ingress.value.to_port protocol = "tcp" cidr_blocks = ingress.value.cidrs } } }. Variable declared as variable "ingress_rules" { type = list(object({ from_port = number, to_port = number, cidrs = list(string) })) }. A bad EC2 instance stuck on a stale user-data run: terraform apply -replace="aws_instance.web" forces destroy + recreate without touching config (replaces the deprecated terraform taint).
fmt -check in CI, validate as offline syntax gate. -replace=ADDRESS supersedes terraform taint.
The Terraform 1.5+ refactor blocks — moved {}, import {}, removed {} — supersede much of the CLI state surgery. Combined with the core functions and expressions, this is the modern objective 8 toolkit.
- Built-in functions to know:
jsonencode()/jsondecode()for JSON marshalling,templatefile(path, vars)for parameterized text,cidrhost()/cidrsubnet()for IP math,coalesce()for "first non-null",try()for fallback values,lookup(map, key, default)andcan()for safe map / expression evaluation. - Conditional expressions:
condition ? true_val : false_val. Common patterns:count = var.create ? 1 : 0for conditional resources, ternaries for tier-basedinstance_type. Nest sparingly — readability degrades fast. - Splat expressions:
module.vpc.private_subnet_ids[*]flattens to a list;aws_instance.web[*].idreturns the ID across all count-indexed instances. Works on count-indexed lists, notfor_eachmaps (use aforexpression there). - moved {} block (Terraform 1.1+): declares state-address refactors in config.
moved { from = aws_instance.old to = aws_instance.new }. On plan, Terraform internally re-keys state — zero diffs, no manualterraform state mv. Stays in the config; can be removed after one apply by all consumers. - import {} block (Terraform 1.5+): declarative adoption of existing infrastructure.
import { to = aws_instance.web id = "i-0abc" }. Pair withterraform plan -generate-config-out=generated.tfto auto-generate the resource block from the API. Removes the need for manualterraform importCLI. - removed {} block (Terraform 1.7+): declarative resource removal without destroying real infrastructure — replaces
terraform state rm.removed { from = aws_s3_bucket.legacy lifecycle { destroy = false } }. Forgets the resource in state; the cloud object lives on.
A team refactors a flat VPC config into a module "network". Old workflow needed terraform state mv by hand on every dev machine. New workflow: drop a moved { from = aws_vpc.main to = module.network.aws_vpc.main } block in config. Every developer's next terraform plan shows zero diffs and re-keys state internally. Later, an out-of-band EC2 needs adopting: write an import { to = aws_instance.legacy id = "i-0abc1234" } block, run terraform plan -generate-config-out=legacy.tf — Terraform writes the resource block from the live attributes. To detach an obsolete S3 bucket without destroying it: removed { from = aws_s3_bucket.legacy lifecycle { destroy = false } }, apply, the bucket lives on but Terraform forgets it.
moved {} = declarative state mv. import {} = declarative state import (+ -generate-config-out). removed {} = declarative state rm (Terraform 1.7+). These three blocks are how modern refactors happen — no more CLI state surgery on every dev's laptop.
Hands-on
Capstone labs
Four labs that exercise the modules end-to-end. Run each in a free-tier AWS account with a $1 budget alarm; tear everything down with terraform destroy when finished. These are the same patterns that recur on the exam — building them once burns the workflow into memory.
Scaffold a project with main.tf, variables.tf, outputs.tf, versions.tf. Use terraform.workspace to interpolate env names into resource tags + bucket prefixes. Switch between terraform workspace new dev and terraform workspace new prod; confirm state isolation by checking the S3 + DynamoDB remote backend writes a different state key per workspace. Verify by running terraform state list after switching workspaces.
Write a modules/vpc module that takes cidr_block, azs, and enable_nat inputs and outputs vpc_id, public_subnet_ids, private_subnet_ids. Add a validation block on cidr_block. Version-pin the module with semantic tags v1.0.0 and publish to a private Git repo. Consume it from a root module via source = "git::https://...?ref=v1.0.0". Bump to v1.1.0 with a backward-compatible change and verify ~> 1.0 picks it up.
Refactor a flat config that uses count to one that uses for_each (re-keying instances by name). Use a moved {} block to preserve state addresses — verify terraform plan shows zero diffs. Then import an out-of-band resource: hand-create an S3 bucket in the console, write an import {} block, run terraform plan -generate-config-out=generated.tf, review, apply. Finally detach a legacy resource with removed {} (Terraform 1.7+) and confirm the cloud object still exists.
Write a GitHub Actions workflow that runs terraform fmt -check, terraform validate, and terraform plan on PRs (with the plan posted as a PR comment), and terraform apply -auto-approve on main-branch pushes. Use the AWS OIDC trust pattern (aws-actions/configure-aws-credentials@v4) to avoid hard-coded credentials — no AWS keys in GitHub Secrets. Pin provider versions in versions.tf with ~> 5.0. Commit .terraform.lock.hcl; gitignore .terraform/ and *.tfvars.
Top 4 mistakes candidates make on the Terraform Associate
- Committing
.tfstateor.tfvarsfiles containing secrets to Git: state goes in a remote backend (S3 + DynamoDB, Azure Blob, or Terraform Cloud); secrets come from env vars, Vault, or sealed-secrets..gitignoremust exclude*.tfstate*,.terraform/, and any*.tfvarsthat holds credentials. - Confusing
countandfor_each:countuses list indices, so removing a middle item re-keys every subsequent resource and force-replaces them.for_eachuses map keys / set members, so insertions and removals are stable. Default tofor_each; reservecountfor "create 0 or 1 conditionally". - Using deprecated
terraform taintor CLI-onlyterraform import: both are superseded byterraform apply -replace=ADDRESSand theimport {}block (Terraform 1.5+) withplan -generate-config-out=. The exam tests the new mechanisms; recognizetaintas legacy. - Skipping the remote state lock setup: with the S3 backend you NEED the
dynamodb_tableargument; Azure Blob has native lease-based locking; GCS has its own native locking. Two engineers runningterraform applyconcurrently without a lock will corrupt state and orphan resources.
Ready for the Terraform Associate?
Scenario-based practice questions across all 9 official objectives. Free, no signup, instant feedback on every answer. Open the Cert Quest path to combine practice questions with mini-game drills and HCL recognition exercises.
Related certifications
Pair Terraform with the cloud + platform stack
Terraform Associate pairs naturally with the AWS / Kubernetes / Docker credentials that the same platform-engineering roles screen for.