HashiCorp · devops

HashiCorp Terraform Associate (003)

The vendor-neutral IaC credential. Master HCL syntax, the initplanapply lifecycle, providers and modules, remote state and workspaces, and the new Terraform 1.5+ blocks (moved, import, removed) that replaced CLI state surgery. Cloud-agnostic from day one.

9Modules
25 hoursDuration
intermediateLevel
Study on the go — CertQuests Podcast

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.

▶ Listen on Spotify
003Exam code (HCTA0-003)
57Exam questions
~70%Passing score
60 minExam duration
$70.50Exam fee (USD)
2 yearsValidity

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
Exam strategy: emphasize the 9 official objectives — the blueprint is unusually faithful to the question pool. Drill 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.

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.

Objective 1 — Understand IaC concepts 30%
Objective 2 — Understand the purpose of Terraform 30%
Objective 3 — Understand Terraform basics 55%
Objective 4 — Use Terraform outside of core workflow 40%
Objective 5 — Interact with Terraform modules 55%
Objective 6 — Use the core Terraform workflow 70%
Objective 7 — Implement and maintain state 80%
Objective 8 — Read, generate, and modify configuration 65%
Objective 9 — Understand HCP Terraform capabilities 50%

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.

01

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.

iac declarative idempotency multi-cloud pulumi cloudformation ansible opentofu
~2h
📖 Read in-depth chapter
Lesson 1.1 What is Infrastructure as Code

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.

Key concepts
  • 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.
Concrete example

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.

Key takeaway: declarative + idempotent + Git-backed is the IaC trifecta. Terraform's value is that it gives you all three for any cloud, not just one.
⚡ Mini-quiz
Drill IaC concept scenarios → study mode (10 questions).
Lesson 1.2 Benefits of IaC & Terraform's Role

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.

Key concepts
  • 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 plan in 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 plan shows exactly what will be created, modified, or destroyed. This safety net prevents surprises and gives teams confidence before applying changes to production.
Concrete example

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.

Key takeaway: Terraform's three exam-canonical wins are the execution plan (preview before apply), the resource graph (dependency-ordered parallel creation), and the provider model (cloud-agnostic). It provisions; it doesn't configure software inside VMs.
⚡ Mini-quiz
Practise IaC benefit scenarios → quick quiz (5 questions).
Lesson 1.3 Comparing IaC tools — Terraform vs Pulumi vs CloudFormation vs Ansible vs Crossplane

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.

Key concepts
  • 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.
Concrete example

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.

Key takeaway: Terraform's killer feature is breadth — one HCL config can manage AWS + Azure + GitHub + Datadog + Cloudflare in a single plan. CloudFormation when AWS-only. Ansible for in-VM config. Crossplane when k8s is already the control plane.
⚡ Mini-quiz
Drill tool-comparison scenarios → study mode (10 questions).
02

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.

terraform-cli hcl init-plan-apply terraform-block heredoc terraform-fmt terraform-validate tfenv
~3h
📖 Read in-depth chapter
Lesson 2.1 Install & Configure Terraform

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.

Key concepts
  • 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 .tf files.
  • Terraform Settings Block: The terraform {} block configures Terraform itself: required_version constrains the CLI version, required_providers declares providers and their version constraints, and backend configures where state is stored.
  • File Structure: Terraform loads all .tf files 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 .gitignore as it contains binaries and can be regenerated at any time.
Concrete example

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).

Key takeaway: 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.
⚡ Mini-quiz
Drill install + setup scenarios → study mode (10 questions).
Lesson 2.2 HCL Syntax: Blocks, Arguments & Expressions

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.

Key concepts
  • 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, and bool. Complex types include list(type) (ordered collection), map(type) (key-value pairs), set(type) (unordered unique values), object({...}) (named attributes), and tuple([...]) (ordered mixed types).
  • Expressions: References use dot notation: aws_instance.web.id accesses 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 ... EOF or indented heredocs <<-EOF ... EOF (which strips leading whitespace). Useful for inline policies, user data scripts, and multi-line configuration values.
Concrete example

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.

Key takeaway: = 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.
⚡ Mini-quiz
Practise HCL-reading scenarios → quick quiz (5 questions).
Lesson 2.3 Terraform Workflow: init, plan, apply, destroy

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.

Key concepts
  • 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 -upgrade to 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.tfplan for 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-approve to 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 -target to destroy specific resources, though this is discouraged for routine use.
  • terraform validate & fmt: validate checks configuration syntax and internal consistency without accessing providers. fmt rewrites files to the canonical HCL format. Both are commonly run in CI pipelines as pre-merge checks.
Concrete example

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.

Key takeaway: plan is read-only and safe to run any time. In production, always save the plan to a file and pass it to apply — that guarantees what was reviewed is what runs.
⚡ Mini-quiz
Drill core-workflow scenarios → study mode (10 questions).
🎧

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 Spotify
03

Providers3 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.

provider required-providers version-constraints lock-file provider-alias oidc github-actions vault
~3h
📖 Read in-depth chapter
Lesson 3.1 Provider Configuration & Versioning

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.

Key concepts
  • 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_providers block using operators: = (exact), >= (minimum), ~> (pessimistic, allows only rightmost version increment). Example: version = "~> 5.0" allows 5.x but not 6.0. The .terraform.lock.hcl file pins exact versions after init.
  • Dependency Lock File: .terraform.lock.hcl records the exact provider versions and hashes selected during terraform 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.
Concrete example

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).

Key takeaway: ~> 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/.
⚡ Mini-quiz
Drill version-constraint scenarios → study mode (10 questions).
Lesson 3.2 Multiple Provider Instances & Aliases

Multi-region deployments — and module-passed providers — depend on aliases. The exam pattern is unmistakable: two provider "aws" blocks, "which resources go where".

Key concepts
  • Provider Aliases: When you need multiple configurations of the same provider (e.g., deploying to two AWS regions), define additional instances with the alias argument: provider "aws" { alias = "west" region = "us-west-2" }. Resources reference aliases with provider = 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 providers meta-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.
Concrete example

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.

Key takeaway: resources without a provider argument use the default (non-aliased). Aliases are addressed as aws.alias_name. Pass them to modules with the providers meta-argument.
⚡ Mini-quiz
Practise alias scenarios → quick quiz (5 questions).
Lesson 3.3 Provider authentication patterns

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.

Key concepts
  • Environment variables: the most portable. AWS: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, optional AWS_SESSION_TOKEN. Azure: ARM_CLIENT_ID, ARM_CLIENT_SECRET, ARM_TENANT_ID, ARM_SUBSCRIPTION_ID. GCP: GOOGLE_APPLICATION_CREDENTIALS path 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 by AWS_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 have sts:AssumeRole on 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@v4 with 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 vault provider plus dynamic-credential engines (AWS, Azure, database) issues per-run credentials that expire after the apply. Never commit .tfvars containing secrets — gitignore them and inject via TF_VAR_* env vars or a Vault data source.
Concrete example

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.

Key takeaway: env vars for CI, shared creds for local dev, instance profiles for self-hosted, OIDC for GitHub/GitLab Actions, Vault for short-lived secrets. Never commit .tfvars with secrets.
⚡ Mini-quiz
Drill authentication-pattern scenarios → study mode (10 questions).
04

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.

resource data-source count for-each depends-on lifecycle timeouts terraform-remote-state
~3h
📖 Read in-depth chapter
Lesson 4.1 Resource Blocks & Lifecycle

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.

Key concepts
  • 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.id creates 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 becomes aws_instance.web[0]. With for_each, it becomes aws_instance.web["key"]. These addresses are used in state commands and -target flags.
  • 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.
Concrete example

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"].

Key takeaway: + create, - destroy, ~ update in-place, -/+ force replacement. The provider docs mark which attributes force replacement — know how to spot them in plan output.
⚡ Mini-quiz
Drill resource-block scenarios → study mode (10 questions).
Lesson 4.2 Meta-Arguments: count, for_each, depends_on, lifecycle

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.

Key concepts
  • count: Creates multiple instances of a resource using an integer. Access individual instances with [index]. Example: count = 3 creates three identical resources. Use count.index inside 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.key and each.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 = true creates the replacement before destroying the original (avoids downtime). prevent_destroy = true blocks any plan that would destroy the resource. ignore_changes tells 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.
Concrete example

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.

Key takeaway: 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.
⚡ Mini-quiz
Practise meta-argument scenarios → quick quiz (5 questions).
Lesson 4.3 Data Sources for Reading External Info

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.

Key concepts
  • 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 as data.aws_ami.latest.id. Data sources use the data block type instead of resource and 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.
Concrete example

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.

Key takeaway: data sources are read-only, prefixed data., refreshed every plan. terraform_remote_state only exposes declared output values from the source config — internal resource attributes are NOT visible.
⚡ Mini-quiz
Drill data-source scenarios → study mode (10 questions).
05

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.

input-variable tfvars variable-precedence validation sensitive output locals terraform-console
~3h
📖 Read in-depth chapter
Lesson 5.1 Input Variables: Types, Defaults, Validation & Sensitive

Variables are how Terraform configs become reusable. The exam hammers variable precedence and the difference between auto-loaded tfvars and CLI-supplied tfvars.

Key concepts
  • Variable Declaration: Defined with variable "name" { type = string default = "value" description = "..." }. Referenced as var.name in 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.tfvars or terraform.tfvars.json (auto-loaded), *.auto.tfvars files (auto-loaded alphabetically), -var-file flag, -var flag 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 = true to 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.
Concrete example

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.

Key takeaway: precedence chain: defaults < TF_VAR_* env < auto-loaded terraform.tfvars + *.auto.tfvars < -var-file < -var. sensitive = true redacts CLI output only — state still holds plaintext.
⚡ Mini-quiz
Drill variable-precedence scenarios → study mode (10 questions).
Lesson 5.2 Output Values & Using Them Across Modules

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.

Key concepts
  • Output Declaration: Defined with output "name" { value = aws_instance.web.public_ip description = "..." }. Outputs are displayed after terraform apply and queryable with terraform output or terraform output -json.
  • Cross-Module References: Child module outputs are accessed as module.module_name.output_name in 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 = true to 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_on can 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_id returns a single value. terraform output -json returns all outputs in JSON format, useful for scripting and integration with other tools.
Concrete example

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.

Key takeaway: outputs are a module's public interface. Internal resources are encapsulated. Use module.x.output_name in the parent; terraform output from the CLI.
⚡ Mini-quiz
Practise output scenarios → quick quiz (5 questions).
Lesson 5.3 Local Values & Expressions

Locals and expressions collapse repeated logic into named, reusable values. The exam tests recognition more than memorization — read for-expressions and splat expressions confidently.

Key concepts
  • Local Values: Defined with locals { common_tags = { Environment = var.env, Project = var.project } }. Referenced as local.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 with terraform console.
  • Conditional Expressions: condition ? true_val : false_val. Common patterns: count = var.create_resource ? 1 : 0 to conditionally create a resource, or instance_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 with if: [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[*].id returns 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.
Concrete example

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.

Key takeaway: locals dry up repeated logic. For-expressions transform; splat ([*]) summarizes attribute lists from count-indexed resources. Use terraform console to test before pasting.
⚡ Mini-quiz
Drill locals + expression scenarios → study mode (10 questions).
06

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.

state remote-backend s3-dynamodb state-locking terraform-refresh state-mv state-rm terraform-import
~4h
📖 Read in-depth chapter
Lesson 6.1 Understanding Terraform State

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.

Key concepts
  • 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 plan and terraform apply include a refresh step by default, making standalone terraform refresh rarely necessary.
Concrete example

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.

Key takeaway: state is required, contains plaintext secrets, must be remote + locked for teams. Local terraform.tfstate is for solo dev only. Encrypt state at rest, restrict access, never commit it.
⚡ Mini-quiz
Drill state-fundamentals scenarios → study mode (10 questions).
Lesson 6.2 Remote State Backends

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.

Key concepts
  • 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 with terraform plan to ensure no unexpected changes.
Concrete example

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.

Key takeaway: S3 + DynamoDB = state storage + lock. 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.
⚡ Mini-quiz
Practise backend scenarios → quick quiz (5 questions).
Lesson 6.3 State Commands: list, show, mv, rm, import

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.

Key concepts
  • 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.web shows 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).
Concrete example

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.

Key takeaway: 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.
⚡ Mini-quiz
Drill state-command scenarios → study mode (10 questions).
07

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.

module module-source registry git-source module-version module-input module-output encapsulation
~4h
📖 Read in-depth chapter
Lesson 7.1 Module Structure & Sources

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.

Key concepts
  • 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 module blocks.
  • Module Structure: A minimal module contains main.tf, variables.tf, and outputs.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 next terraform init or 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.
Concrete example

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.

Key takeaway: Registry modules need version; Git modules need ?ref=; local modules are read from disk. terraform init resolves remote sources; never commit .terraform/modules/.
⚡ Mini-quiz
Drill module-source scenarios → study mode (10 questions).
Lesson 7.2 Input & Output Variables in Modules

Modules are functions. Inputs are parameters; outputs are return values; the implementation is private. The exam tests this metaphor directly.

Key concepts
  • 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.
Concrete example

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.

Key takeaway: only declared output values are visible to callers. Variable validation blocks give consumers clear error messages. Cross-module dependencies are derived automatically from output references.
⚡ Mini-quiz
Practise module-interface scenarios → quick quiz (5 questions).
Lesson 7.3 Module Versioning & Best Practices

Unpinned modules are how production breaks at 2am. Master version constraints (same syntax as providers), semver, and the composition patterns that keep things sane.

Key concepts
  • Module Versioning: Registry modules support the version argument 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.1 allows 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.
Concrete example

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.

Key takeaway: always pin module versions. Registry: version argument. Git: ?ref= tag. Favor shallow composition; deeply nested modules are debugging hell.
⚡ Mini-quiz
Drill module-versioning scenarios → study mode (10 questions).
08

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.

workspace terraform-cloud hcp-terraform sentinel opa vcs-workflow private-registry run-triggers
~3h
📖 Read in-depth chapter
Lesson 8.1 Workspaces: CLI vs Terraform Cloud

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.

Key concepts
  • 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 via terraform.workspace for conditional logic.
  • CLI Workspace State: Each workspace stores its state in a separate file under terraform.tfstate.d/[workspace_name]/. The default workspace uses terraform.tfstate in 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.
Concrete example

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.

Key takeaway: CLI workspaces = state separation only. TFC workspaces = state + variables + creds + access + history. Don't use CLI workspaces for production dev/staging/prod separation.
⚡ Mini-quiz
Drill workspace-distinction scenarios → study mode (10 questions).
Lesson 8.2 Terraform Cloud Features

Terraform Cloud (now HCP Terraform) wraps the core CLI with collaboration, governance, and policy enforcement. Objective 9 expects awareness-level knowledge of these features.

Key concepts
  • Remote Runs: Terraform Cloud executes terraform plan and apply in 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.
Concrete example

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).

Key takeaway: Sentinel runs between plan and apply with advisory / soft-mandatory / hard-mandatory enforcement levels. Execution modes: remote, local, agent. Free tier supports up to 500 managed resources.
⚡ Mini-quiz
Practise TFC-feature scenarios → quick quiz (5 questions).
Lesson 8.3 VCS-driven workflows + private module registry

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.

Key concepts
  • 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 plan and 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 a version constraint. 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.
Concrete example

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.

Key takeaway: VCS connection → speculative plans on PRs → Sentinel/OPA gates → merge triggers apply → run triggers chain stacks. Private registry standardizes the modules everyone consumes.
⚡ Mini-quiz
Drill VCS-workflow scenarios → study mode (10 questions).
09

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 {}.

provisioner local-exec dynamic-block templatefile moved-block import-block removed-block replace-flag
~3h
📖 Read in-depth chapter
Lesson 9.1 Provisioners & When to Use Them

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.

Key concepts
  • 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 connection block 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 = destroy to 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.
Concrete example

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.

Key takeaway: provisioners are a last resort. Prefer user_data, Packer-built AMIs, or Ansible run separately. Provisioner failure marks the resource tainted → next apply destroys + recreates it.
⚡ Mini-quiz
Drill provisioner scenarios → study mode (10 questions).
Lesson 9.2 Dynamic Blocks, Type Constraints & Utility Commands

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).

Key concepts
  • 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. The any keyword 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 -check in 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 by terraform 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.
Concrete example

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).

Key takeaway: dynamic blocks for variable-count nested blocks. fmt -check in CI, validate as offline syntax gate. -replace=ADDRESS supersedes terraform taint.
⚡ Mini-quiz
Practise dynamic-block scenarios → quick quiz (5 questions).
Lesson 9.3 Functions, expressions & moved/imported/removed blocks

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.

Key concepts
  • 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) and can() for safe map / expression evaluation.
  • Conditional expressions: condition ? true_val : false_val. Common patterns: count = var.create ? 1 : 0 for conditional resources, ternaries for tier-based instance_type. Nest sparingly — readability degrades fast.
  • Splat expressions: module.vpc.private_subnet_ids[*] flattens to a list; aws_instance.web[*].id returns the ID across all count-indexed instances. Works on count-indexed lists, not for_each maps (use a for expression 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 manual terraform 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 with terraform plan -generate-config-out=generated.tf to auto-generate the resource block from the API. Removes the need for manual terraform import CLI.
  • 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.
Concrete example

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.

Key takeaway: 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.
⚡ Mini-quiz
Drill function + refactor-block scenarios → study mode (10 questions).

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.

Lab 1 — Multi-environment dev/prod split with workspaces

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.

Lab 2 — Build a reusable module

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.

Lab 3 — State migration drill

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.

Lab 4 — CI/CD pipeline with OIDC

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 .tfstate or .tfvars files 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. .gitignore must exclude *.tfstate*, .terraform/, and any *.tfvars that holds credentials.
  • Confusing count and for_each: count uses list indices, so removing a middle item re-keys every subsequent resource and force-replaces them. for_each uses map keys / set members, so insertions and removals are stable. Default to for_each; reserve count for "create 0 or 1 conditionally".
  • Using deprecated terraform taint or CLI-only terraform import: both are superseded by terraform apply -replace=ADDRESS and the import {} block (Terraform 1.5+) with plan -generate-config-out=. The exam tests the new mechanisms; recognize taint as legacy.
  • Skipping the remote state lock setup: with the S3 backend you NEED the dynamodb_table argument; Azure Blob has native lease-based locking; GCS has its own native locking. Two engineers running terraform apply concurrently 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.

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.

Start practicing →