Day 23: Terraform Associate (004) Exam — Complete Cheat Sheet

Day 23: Terraform Associate (004) Exam — Complete Cheat Sheet

Day 23 shifts to certification prep. This post is a domain-by-domain reference covering every published exam objective for the current Terraform Associate (004) revision, anchored to the concepts built over the previous 22 days. Where a concept appeared in the series, the day is noted — most of this is not new content, it is the same content organized for exam review.

Day 23 shifts to certification prep. This post is a domain-by-domain reference covering every published exam objective for the current Terraform Associate (004) revision, anchored to the concepts built over the previous 22 days. Where a concept appeared in the series, the day is noted — most of this is not new content, it is the same content organized for exam review.

About the numbers below. HashiCorp publishes the Terraform Associate (004) exam objectives but does not publish per-domain weightings or the exact passing score. The 004 exam content list shows objectives only — no percentages. Any weightings you see online are unofficial estimates. Treat all objectives as in-scope; do not rely on percentage breakdowns to skip topics.

What's new in 004 vs 003. The 004 revision restructured the exam from 9 domains to 8 domains and added several topics that were not in 003: check blocks and custom conditions for validation (4g), the moved and removed blocks for state refactoring (6d), explicit coverage of secrets management with the Vault provider (4h), and a substantially expanded HCP Terraform domain (8) covering Projects, Change Requests, Dynamic Provider Credentials, OPA policy enforcement, Health (drift detection), and the Explorer. The declarative import {} block, terraform test with mock_provider, and the cloud {} block introduced in 003 all remain in scope. This series has covered everything in 004 — if you came up through the 22 days, you have already seen it.

Exam Overview

Detail Value
Exam name HashiCorp Certified: Terraform Associate (004)
Format ~57 multiple choice / multiple select questions
Duration 60 minutes
Passing score Not officially published (community estimate ~70%)
Valid for 2 years
Delivered by PSI (online proctored)

Time budget: 60 minutes ÷ 57 questions ≈ 63 seconds per question. Flag anything you are not sure about on the first pass and come back — do not burn 4 minutes on one question.

Domains (Terraform Associate 004)

The 004 exam content list groups objectives into 8 domains, with no published per-domain weightings:

# Domain
1 Infrastructure as Code (IaC) with Terraform
2 Terraform fundamentals (providers, state purpose)
3 Core Terraform workflow (init, validate, plan, apply, destroy, fmt)
4 Terraform configuration (resources, data, variables, outputs, complex types, expressions, custom conditions, sensitive data + Vault)
5 Terraform modules
6 Terraform state management (local/remote backends, locking, drift, moved and removed blocks)
7 Maintain infrastructure with Terraform (import, state inspection, logging)
8 HCP Terraform (workspaces, Projects, Change Requests, Dynamic Credentials, OPA, Health/drift, Variable Sets, Run Triggers)

The section headings below retain the older 9-domain numbering used in earlier revisions because that organization is friendlier for sequential review. Every objective in the 004 content list is covered — the new 004-specific topics (check blocks, moved/removed blocks, Vault, HCP Projects/Change Requests/Dynamic Credentials/OPA) appear inline in the relevant section.

Domain 1 — Understand IaC Concepts (5%)

What IaC solves

Problem without IaC IaC solution
Manual steps, not reproducible Config is code — same result every run
No history of what changed Every change is a git commit
"Worked on my machine" Identical environment from the same code
Undocumented manual steps Code is the documentation
Slow, error-prone provisioning Automated, consistent, fast

IaC approaches

Style Description Terraform?
Declarative Describe the desired end state; the tool figures out how ✅ Yes
Procedural Describe the steps to get there (scripts, Ansible playbooks) ✗ No
Imperative Same as procedural — AWS SDK scripts, boto3 ✗ No

Terraform is declarative. You write what you want; Terraform computes the diff against current state and figures out the order and method.

Idempotency

Running terraform apply on an already-applied configuration produces no changes. The plan shows "No changes. Infrastructure is up-to-date." This is idempotency — the same input always produces the same output, regardless of how many times you run it.

Domain 2 — Understand the Purpose of Terraform (5%)

Terraform vs other IaC tools

Tool Type Cloud scope State management Language
Terraform Declarative IaC Multi-cloud (providers) Yes — state file HCL
CloudFormation Declarative IaC AWS only Yes — stack drift detection JSON/YAML
Pulumi Declarative IaC Multi-cloud Yes Python, TS, Go, C#
Ansible Procedural config mgmt Multi-cloud No (agentless, push) YAML
Packer Image builder Multi-cloud No HCL
Chef/Puppet Config management Multi-cloud Yes (agent-based) Ruby DSL

The exam frequently asks: Terraform vs Ansible. Terraform provisions infrastructure (creates EC2 instances, RDS databases, VPCs). Ansible configures software on existing servers. They are complementary, not competing — a common pattern is Terraform to provision, Ansible to configure.

Terraform's multi-cloud value

The same HCL workflow — init, plan, apply — works across AWS, Azure, GCP, Kubernetes, GitHub, Datadog, and thousands of other providers. The provider handles the API translation; the workflow and language are always the same.

Domain 3 — Understand Terraform Basics (15%)

The terraform settings block

terraform {
  required_version = ">= 1.6.0"    # minimum Terraform version

  required_providers {
    aws = {
      source  = "hashicorp/aws"     # registry.terraform.io/hashicorp/aws
      version = "~> 5.0"            # any 5.x version
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.0"
    }
  }

  backend "s3" {                    # cannot use variables here
    bucket         = "mnourdine-tf-state"
    key            = "prod/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}

Key rule: the backend block cannot reference variables or locals. All values must be literal strings. This is why backend configuration sometimes uses partial configuration with -backend-config flags on terraform init.

Provider configuration and aliases (Day 14)

# Default provider — used by all aws_* resources unless overridden
provider "aws" {
  region = "us-east-1"
}

# Aliased provider — must be explicitly referenced by resources/modules
provider "aws" {
  alias  = "eu"
  region = "eu-west-1"
}

# Resource using the aliased provider
resource "aws_s3_bucket" "eu_logs" {
  provider = aws.eu       # syntax: <provider_type>.<alias>
  bucket   = "my-eu-logs"
}

# Module receiving an aliased provider
module "web_eu" {
  source    = "./modules/web"
  providers = { aws = aws.eu }
}

Input variables — complete syntax

variable "instance_type" {
  description = "EC2 instance type"
  type        = string
  default     = "t2.micro"

  validation {
    condition     = contains(["t2.micro", "t3.small", "t3.medium"], var.instance_type)
    error_message = "instance_type must be t2.micro, t3.small, or t3.medium."
  }

  sensitive = false   # if true, value is redacted in plan/apply output
  nullable  = false   # if false, null is not a valid value
}

Precedence order (highest wins):

  1. -var flag on the command line
  2. -var-file flag on the command line
  3. *.auto.tfvars and *.auto.tfvars.json files (loaded together in lexical order)
  4. terraform.tfvars.json
  5. terraform.tfvars
  6. TF_VAR_<name> environment variables
  7. Default value in the variable block

Note: all *.auto.tfvars and *.auto.tfvars.json files are processed together in lexical filename order — .json files are not categorically ranked above non-JSON files.

Variable types

Type Example
string "t3.micro"
number 3
bool true
list(string) ["us-east-1a", "us-east-1b"]
set(string) toset(["a", "b"]) — unordered, unique
map(string) { Environment = "prod" }
object({...}) { name = string, port = number }
tuple([...]) [string, number, bool] — fixed length, mixed types
any Accept any type — avoid in module inputs

Output values

output "alb_dns_name" {
  description = "DNS name of the Application Load Balancer"
  value       = aws_lb.web.dns_name
  sensitive   = false    # if true, value is redacted in output but stored in state
  depends_on  = [aws_lb_listener.http]   # rarely needed, but valid
}

Local values

locals {
  name_prefix = "${var.environment}-${var.app_name}"
  all_tags    = merge(var.tags, { ManagedBy = "terraform" })
  is_prod     = var.environment == "prod"
}

# Reference as: local.name_prefix

Locals are evaluated once and reusable anywhere in the config. They cannot be set from outside (unlike variables) and cannot be outputs.

Data sources

# Read existing AWS resources without managing them
data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["amzn2-ami-hvm-*-x86_64-gp2"]
  }
}

# Reference: data.aws_ami.amazon_linux.id

Data sources are read-only. They fetch information about existing resources but do not create, update, or delete anything.

Domain 4 — Use Terraform Outside the Core Workflow (10%)

terraform import

Brings an existing resource under Terraform management by adding it to state. The resource must already be written in the config.

# Syntax: terraform import <resource_address> <resource_id>
terraform import aws_s3_bucket.logs my-existing-bucket
terraform import aws_security_group.web sg-0abc1234567890abc
terraform import aws_db_instance.main my-rds-identifier

After import, run terraform plan — it should show no changes if the config matches the real resource. If it shows changes, update the config to match.

Declarative import {} block (Terraform 1.5+)

The modern, code-reviewable alternative to the CLI command. The block is added to a .tf file and removed after the import has applied:

import {
  to = aws_s3_bucket.logs
  id = "my-existing-bucket"
}

resource "aws_s3_bucket" "logs" {
  bucket = "my-existing-bucket"
}

The import is then performed by the next terraform plan / terraform apply. The block can also be combined with terraform plan -generate-config-out=imported.tf to scaffold the resource configuration.

terraform state subcommands

Command What it does
terraform state list List all resources in state
terraform state show <address> Show all attributes of a resource
terraform state mv <src> <dst> Rename a resource in state (without destroying/recreating)
terraform state rm <address> Remove a resource from state (it stays in AWS)
terraform state pull Download raw state file as JSON
terraform state push <file> Upload a state file (dangerous — use with caution)

state mv use case: renaming a resource or moving it into a module without recreating it:

# Rename aws_instance.server to aws_instance.web
terraform state mv aws_instance.server aws_instance.web

# Move a resource into a module
terraform state mv aws_lb.main module.alb.aws_lb.main

Debugging with TF_LOG

# Log levels: TRACE, DEBUG, INFO, WARN, ERROR (most → least verbose)
export TF_LOG=DEBUG
export TF_LOG_PATH=./terraform.log
terraform apply

# Disable logging
unset TF_LOG

TF_LOG=TRACE is the most verbose level — it shows every API call made to every provider. Use it when a resource is failing with an opaque AWS error.

terraform console

An interactive REPL for evaluating expressions against the current state:

terraform console
> cidrsubnet("10.0.0.0/16", 8, 1)
"10.0.1.0/24"
> length(["a", "b", "c"])
3
> jsondecode("{\"key\": \"value\"}")
{ "key" = "value" }
> var.environment
"prod"

Useful for testing cidrsubnet values before writing them into the config.

terraform fmt and terraform validate

terraform fmt -recursive            # format all .tf files in subdirectories
terraform fmt -recursive -check     # exit non-zero if any file needs formatting (CI use)
terraform fmt -diff                 # show what would change without writing

terraform validate                  # validate config without accessing state or providers
terraform init -backend=false       # required before validate if no backend is configured

Domain 5 — Interact with Terraform Modules (15%)

Module sources

Source type Example
Local path source = "./modules/asg"
Public Terraform Registry source = "hashicorp/consul/aws"
GitHub (HTTPS) source = "github.com/mohamednourdine/terraform-modules//modules/asg"
GitHub (SSH) source = "git@github.com:mohamednourdine/terraform-modules.git//modules/asg"
Git with tag source = "git::https://github.com/...//modules/asg?ref=v1.5.0"
HCP Terraform private registry source = "app.terraform.io/org/asg/aws"
S3 bucket source = "s3::https://s3.amazonaws.com/my-bucket/module.zip"

The // separates the repository URL from the subdirectory path within the repository.

Module structure (Day 9)

modules/asg/
├── main.tf          ← resources
├── variables.tf     ← input variables (var.*)
├── outputs.tf       ← output values
├── versions.tf      ← required_providers
└── README.md        ← terraform-docs generated

A module is any directory containing .tf files. No special declaration is needed — Terraform detects it by the presence of .tf files.

Passing providers to modules (Day 14)

# Module must declare the providers it uses
# modules/asg/versions.tf
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

# Root config passes the provider instance
module "asg_eu" {
  source    = "./modules/asg"
  providers = {
    aws = aws.eu    # pass the aliased provider
  }
}

Without the providers argument, the module uses the default (unaliased) provider.

Module versioning (Day 9)

module "asg" {
  source  = "app.terraform.io/mohamednourdine-org/asg/aws"
  version = "~> 1.5"    # >= 1.5.0, < 2.0.0
}

Version constraint operators:

Operator Meaning
= 1.5.0 Exact version only
!= 1.5.0 Anything except this version
> 1.5.0 Greater than
>= 1.5.0 Greater than or equal
< 2.0.0 Less than
<= 2.0.0 Less than or equal
~> 1.5 >= 1.5.0, < 2.0.0 (allow minor + patch)
~> 1.5.0 >= 1.5.0, < 1.6.0 (allow patch only)

~> is the "pessimistic constraint" — the most common for production use.

terraform init flags

terraform init                      # download providers + modules, initialize backend
terraform init -upgrade             # upgrade providers/modules within constraints
terraform init -reconfigure         # reinitialize backend, ignore existing state
terraform init -migrate-state       # migrate state to the new backend
terraform init -backend=false       # skip backend initialization (for validate/fmt in CI)
terraform init -backend-config=backend.hcl  # partial backend configuration

Public registry vs private registry

Public Registry (registry.terraform.io) HCP Terraform Private Registry
Access Open to everyone Organization members only
Module source hashicorp/consul/aws app.terraform.io/org/module/provider
Documentation Auto-generated Auto-generated (same engine)
Versioning Semantic tags on GitHub Same
Verification HashiCorp-verified badge available N/A

Domain 6 — Use the Core Terraform Workflow (20%)

The workflow

Write → Plan → Apply
  │        │       │
Edit    Review   Execute
.tf     output   changes
files

Detailed:

terraform init       # 1. Download providers, modules, configure backend
terraform validate   # 2. Check syntax and config validity
terraform plan       # 3. Show what will change
terraform apply      # 4. Apply the changes
terraform destroy    # 5. Destroy all managed resources

terraform plan flags

terraform plan -out=tfplan              # save plan to file (use with apply)
terraform plan -var="env=prod"          # pass a variable
terraform plan -var-file="prod.tfvars"  # load variables from file
terraform plan -target=aws_lb.web       # plan only this resource and its dependencies
terraform plan -destroy                 # show what destroy would do
terraform plan -replace=aws_instance.web # plan a force-replacement of this resource
terraform plan -refresh=false           # skip refreshing state (faster, may miss drift)
terraform plan -refresh-only            # show drift between state and real infra without proposing changes
terraform plan -compact-warnings        # suppress warning details

terraform apply flags

terraform apply                          # plan + prompt for approval
terraform apply -auto-approve            # skip the approval prompt (CI use)
terraform apply tfplan                   # apply a saved plan file — no new plan generated
terraform apply -target=aws_lb.web       # apply only this resource
terraform apply -replace=aws_instance.web # force-replace a specific resource
terraform apply -var="env=prod"          # pass a variable at apply time
terraform apply -parallelism=10          # default is 10 concurrent operations

terraform destroy flags

terraform destroy                        # destroy all — prompts for approval
terraform destroy -auto-approve          # skip prompt
terraform destroy -target=aws_db_instance.main  # destroy only this resource

CLI Workspaces (Day 17)

terraform workspace list                # list all workspaces (* = current)
terraform workspace new sandbox         # create and switch to sandbox workspace
terraform workspace select prod         # switch to existing workspace
terraform workspace show                # show current workspace name
terraform workspace delete sandbox      # delete a workspace (must not be current)

Use terraform.workspace in config to branch on the current workspace:

locals {
  instance_type = terraform.workspace == "prod" ? "t3.medium" : "t2.micro"
}

Exam distinction: CLI workspaces ≠ HCP Terraform workspaces (covered in Day 19). The CLI terraform workspace command has no effect on HCP Terraform workspace selection.

Domain 7 — Implement and Maintain State (15%)

What state does

The state file (.tfstate) maps Terraform resource addresses to real infrastructure IDs. Without it, Terraform cannot know which AWS resource corresponds to aws_lb.web — it would try to create a new one every time.

The state file also stores:

  • The output values of all root module outputs
  • Dependencies between resources (for correct destroy ordering)
  • Metadata used by providers (e.g., the schema version of each resource)

Backend types

Backend State stored in Locking
local (default) terraform.tfstate on disk None
s3 AWS S3 bucket DynamoDB (optional but required for teams)
azurerm Azure Blob Storage Blob storage native locking
gcs Google Cloud Storage GCS native locking
remote (legacy) / cloud block HCP Terraform Built-in

S3 backend configuration (Day 9)

terraform {
  backend "s3" {
    bucket         = "mnourdine-tf-state"
    key            = "prod/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "terraform-locks"    # table must have partition key "LockID" (string)
    encrypt        = true                 # SSE-S3 encryption at rest
  }
}

The S3 bucket and DynamoDB table must be created manually before terraform init — Terraform cannot bootstrap its own backend.

HCP Terraform backend (cloud block, Day 19)

terraform {
  cloud {
    organization = "mohamednourdine-org"
    workspaces {
      # Either select one workspace by name:
      name = "fastapi-prod"
      # OR select multiple workspaces by tag (mutually exclusive with name):
      # tags = ["fastapi", "prod"]
    }
  }
}

Authenticate: terraform login — saves a token to ~/.terraform.d/credentials.tfrc.json. Defaults to app.terraform.io; pass a hostname for self-hosted Terraform Enterprise: terraform login tfe.example.com.

State locking

State locking prevents concurrent operations that could corrupt state. When a lock is acquired (by plan, apply, or destroy), other operations that require state wait.

# Force-unlock a stuck lock (use with caution — only if the process is truly dead)
terraform force-unlock <lock-id>

terraform state key operations

# Inspect
terraform state list                    # list all resource addresses
terraform state show aws_lb.web         # full attribute dump of one resource

# Restructure (does not touch real AWS resources)
terraform state mv aws_lb.web module.alb.aws_lb.web   # move into a module
terraform state mv 'aws_instance.web[0]' 'aws_instance.web["primary"]'  # count → for_each

# Remove (resource stays in AWS, Terraform stops tracking it)
terraform state rm aws_instance.temp

# Import (add existing AWS resource to state)
terraform import aws_s3_bucket.logs my-bucket-name

Sensitive values in state

Sensitive values (from sensitive = true on variables/outputs, or from sensitive provider attributes like aws_secretsmanager_secret_version.secret_string) are stored in the state file in plaintext. The sensitive flag only prevents them from appearing in terminal output — it does not encrypt them in the state file.

Protect the state file with:

  • S3 server-side encryption (encrypt = true)
  • S3 bucket policy restricting access
  • DynamoDB table access policy
  • HCP Terraform (encrypts state at rest automatically)

terraform refresh

terraform refresh    # deprecated as a standalone command in Terraform 0.15.4

terraform refresh updated the state to match real infrastructure without creating a plan. It is now integrated into terraform plan (which always refreshes by default) and terraform apply. Two modern equivalents:

  • terraform plan -refresh-onlyshows drift between state and real infrastructure without writing any changes back to state.
  • terraform apply -refresh-onlypersists the refresh: prompts for approval, then writes the updated state. This is the true replacement for the old terraform refresh command.

moved and removed blocks (004 objective)

Declarative state refactoring — the code-reviewable equivalents of terraform state mv and terraform state rm. Unlike the CLI commands, these blocks are committed to the repo, so every collaborator's terraform apply performs the same state operation.

moved block (Terraform 1.1+) — rename a resource or move it into a module without recreating it:

# Old address: aws_instance.server
# New address: aws_instance.web
moved {
  from = aws_instance.server
  to   = aws_instance.web
}

resource "aws_instance" "web" {
  # ... unchanged config ...
}
# Move a resource into a module without destroy/recreate
moved {
  from = aws_lb.main
  to   = module.alb.aws_lb.main
}

The moved block can be deleted after every workspace has applied it once (it is a one-shot migration), but leaving it in is safe — it becomes a no-op once state matches.

removed block (Terraform 1.7+) — remove a resource from state without destroying the real infrastructure (the declarative equivalent of terraform state rm):

removed {
  from = aws_s3_bucket.legacy_logs

  lifecycle {
    destroy = false   # required: state-only removal, leave real bucket alone
  }
}

Use removed when an existing resource is being adopted by another tool or another Terraform configuration and you want to stop managing it without deleting it.

Domain 8 — Read, Generate, and Modify Configuration (15%)

Meta-arguments on resources

resource "aws_instance" "web" {
  ami           = "ami-0abc123"
  instance_type = "t3.micro"

  count    = 3                        # creates web[0], web[1], web[2]
  # OR
  for_each = toset(["a", "b", "c"])  # creates web["a"], web["b"], web["c"]

  depends_on = [aws_security_group.web]   # explicit dependency when implicit is insufficient

  provider = aws.eu                   # assign to a non-default provider

  lifecycle {
    create_before_destroy = true      # new resource ready before old one is deleted
    prevent_destroy       = true      # block any destroy of this resource
    ignore_changes        = [ami]     # don't treat ami changes as drift
    replace_triggered_by  = [aws_launch_template.web.latest_version]  # replace when this changes
  }
}

count vs for_each

count for_each
Input Number map or set(string)
Index key Integer ([0], [1]) Map key or set element (["key"])
Removing one item Renumbers all subsequent resources (dangerous) Removes only the keyed resource
Recommended for Identical resources, toggle (0 or 1) Distinct named resources
Referencing aws_instance.web[0] aws_instance.web["primary"]

Exam trap: removing the middle item from a count = 3 list renumbers [2] to [1], causing Terraform to destroy and recreate [1]. Use for_each with maps to avoid this.

for expressions

# List transformation
[for s in var.names : upper(s)]
# → ["ALICE", "BOB"]

# Map transformation
{for k, v in var.tags : k => upper(v)}
# → { Environment = "PROD" }

# Filtering
[for s in var.names : s if length(s) > 3]
# → names with more than 3 characters

# Object → list of values
[for k, v in aws_instance.web : v.private_ip]

Splat expressions

# Traditional (count-based resources)
aws_instance.web[*].private_ip        # list of all private IPs

# Full splat (works on any list)
var.users[*].name

Conditional expression

# condition ? true_value : false_value
instance_type = var.environment == "prod" ? "t3.medium" : "t2.micro"
count         = var.enable_monitoring ? 1 : 0

Essential built-in functions

String functions

format("Hello, %s!", var.name)          # string interpolation
formatdate("YYYY-MM-DD", timestamp())   # date formatting
lower("PROD")                           # → "prod"
upper("prod")                           # → "PROD"
replace("hello world", " ", "-")        # → "hello-world"
trimspace("  hello  ")                  # → "hello"
split(",", "a,b,c")                     # → ["a", "b", "c"]
join("-", ["a", "b", "c"])              # → "a-b-c"

Collection functions

length(var.subnet_ids)                  # count of elements
contains(["a", "b"], "a")              # → true
lookup(var.tags, "Environment", "dev") # map lookup with default
merge({a=1}, {b=2})                    # → {a=1, b=2}
flatten([[1,2],[3,4]])                  # → [1,2,3,4]
toset(["a","b","a"])                    # → {"a","b"} (deduplicates)
tolist(var.set_value)                   # set → list (order not guaranteed)
tomap({a="1", b="2"})                  # convert to map
keys({a=1, b=2})                        # → ["a", "b"]
values({a=1, b=2})                      # → [1, 2]
zipmap(["a","b"], [1,2])               # → {a=1, b=2}

Encoding functions

jsonencode({key = "value"})            # → "{\"key\":\"value\"}"
jsondecode("{\"key\":\"value\"}")      # → {key = "value"}
base64encode("hello")                  # → "aGVsbG8="
base64decode("aGVsbG8=")              # → "hello"

Filesystem functions

file("./scripts/user_data.sh")         # read file contents as string
templatefile("./tmpl/init.tpl", {
  db_host = aws_db_instance.main.endpoint
})                                      # render a template with variables

Networking functions

cidrsubnet("10.0.0.0/16", 8, 1)       # → "10.0.1.0/24"
cidrhost("10.0.1.0/24", 5)            # → "10.0.1.5"
cidrnetmask("10.0.1.0/24")            # → "255.255.255.0"

Type conversion

tostring(42)                           # → "42"
tonumber("42")                         # → 42
tobool("true")                         # → true

Error handling

try(jsondecode(var.json_string), {})   # return {} if jsondecode fails
coalesce("", null, "fallback")         # → "fallback" (first non-empty, non-null)
coalesce(var.override, var.default)    # use override if set, else default
one([])                                # → null; one(["a"]) → "a"; one(["a","b"]) → error

Dynamic blocks (Day 10)

resource "aws_security_group" "web" {
  name = "web-sg"

  dynamic "ingress" {
    for_each = var.ingress_ports   # map(number) or list
    content {
      from_port   = ingress.value
      to_port     = ingress.value
      protocol    = "tcp"
      cidr_blocks = ["0.0.0.0/0"]
    }
  }
}

Dynamic blocks eliminate repetitive nested blocks. The iterator name defaults to the block type (ingress) but can be set with iterator = rule to use rule.value instead.

Heredoc strings

user_data = <<-EOF
  #!/bin/bash
  set -e
  yum install -y python3
  EOF
# The - prefix strips leading whitespace from each line up to the indentation
# level of the closing marker (EOF). It does NOT strip all leading whitespace —
# relative indentation inside the heredoc is preserved.

Custom conditions and check blocks (004 objective)

004 explicitly tests validation beyond simple variable.validation. Three constructs cover the full set:

1. Variable validation (already shown above):

variable "instance_type" {
  type = string
  validation {
    condition     = can(regex("^t3\\.", var.instance_type))
    error_message = "instance_type must be a t3 family instance."
  }
}

2. Resource preconditions and postconditions (Terraform 1.2+) — assertions evaluated during plan/apply against actual resource attributes:

data "aws_ami" "app" {
  most_recent = true
  owners      = ["self"]
  filter { name = "tag:Application"; values = ["fastapi"] }

  lifecycle {
    postcondition {
      condition     = self.architecture == "x86_64"
      error_message = "AMI ${self.id} must be x86_64 architecture."
    }
  }
}

resource "aws_instance" "web" {
  ami           = data.aws_ami.app.id
  instance_type = var.instance_type

  lifecycle {
    precondition {
      condition     = data.aws_ami.app.tags["Environment"] == var.environment
      error_message = "AMI environment tag must match var.environment."
    }
  }
}

A precondition runs before the resource is created/updated; a postcondition runs after. Failures abort the plan.

3. check blocks (Terraform 1.5+) — standalone health assertions evaluated after apply. Unlike preconditions/postconditions, a failed check produces a warning, not an error — useful for monitoring conditions that should be true but you do not want to abort apply on:

check "alb_health" {
  data "http" "alb" {
    url = "https://${aws_lb.web.dns_name}/health"
  }

  assert {
    condition     = data.http.alb.status_code == 200
    error_message = "ALB health endpoint returned ${data.http.alb.status_code}."
  }
}

In HCP Terraform, check block results feed into the Health dashboard and Continuous Validation (Plus tier) — the workspace surfaces a warning when an assert starts failing on a periodic re-check.

Sensitive data and the Vault provider (004 objective 4h)

004 explicitly tests secrets management with Vault, alongside the basic sensitive = true mechanic.

Basic protection (already covered above):

  • sensitive = true on variables and outputs — redacts terminal output, does not encrypt state.
  • State file remains plaintext on disk; protect with S3 SSE + bucket policy or HCP Terraform.

Vault provider — fetch secrets from HashiCorp Vault at plan/apply time so the secret value is never written into the configuration:

terraform {
  required_providers {
    vault = { source = "hashicorp/vault", version = "~> 4.0" }
  }
}

provider "vault" {
  address = "https://vault.example.com:8200"
  # Auth typically via VAULT_TOKEN env var or auth backend (AppRole, AWS IAM, JWT/OIDC)
}

# Read a KV v2 secret
data "vault_kv_secret_v2" "db" {
  mount = "kv"
  name  = "prod/database"
}

resource "aws_db_instance" "main" {
  username = data.vault_kv_secret_v2.db.data["username"]
  password = data.vault_kv_secret_v2.db.data["password"]
  # ... rest of config ...
}

Important caveat the exam tests: the secret value fetched by data.vault_kv_secret_v2 is still written into the state file (because Terraform records all data source attributes). Vault solves the configuration problem (no plaintext secret in .tf files or VCS) but not the state problem (still encrypt state at rest). The two protections are complementary.

Alternative pattern: use Vault's dynamic secrets engines (e.g., vault_database_secret_backend_role) to generate short-lived credentials at apply time, so a leaked state file exposes only an already-rotated credential.

Domain 9 — Understand HCP Terraform Capabilities (8%)

Full coverage in Days 19–22. Key exam points:

CLI workspaces vs HCP Terraform workspaces

CLI workspace HCP Terraform workspace
Created with terraform workspace new HCP Terraform UI / API
Stores Alternate state file Full deployment unit (state + variables + runs + team access)
Access control None Team-based RBAC
Variables -var, .tfvars, env vars UI/API, encrypted at rest
When to use Ephemeral sandbox/test envs Long-lived dev/staging/prod

HCP Terraform execution modes

Mode Where runs execute
Remote (default) HCP Terraform managed environment
Local Your machine (HCP Terraform stores state only)
Agent Self-hosted agent in private network

Sentinel enforcement levels

Level Blocks apply? Override?
Advisory No N/A
Soft Mandatory Yes Admin can override with justification
Hard Mandatory Yes Nobody can override

OPA policy enforcement (004 objective)

Alongside Sentinel, HCP Terraform now supports Open Policy Agent (OPA) as a policy language. OPA policies are written in Rego and evaluated against the same plan JSON that Sentinel sees:

# policies/imdsv2.rego
package terraform.imdsv2

deny[msg] {
  resource := input.resource_changes[_]
  resource.type == "aws_launch_template"
  resource.change.actions[_] == "create"
  resource.change.after.metadata_options[0].http_tokens != "required"
  msg := sprintf("Launch template %s must enforce IMDSv2.", [resource.address])
}

OPA policy sets attach to workspaces the same way Sentinel sets do, and use the same enforcement levels (advisory / mandatory). OPA is the right choice if your organization already standardizes on Rego (Kubernetes admission via Gatekeeper, conftest in CI), since the same .rego policy can run in CI and in HCP Terraform.

Dynamic Provider Credentials (004 objective)

The HCP Terraform feature that eliminates static cloud credentials in workspace variables. Instead of storing AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY as workspace env vars, HCP Terraform exchanges a short-lived OIDC token for an AWS IAM role assumption at run time:

Workspace settings → Dynamic Credentials
├── Provider:    aws
├── Trust:       arn:aws:iam::123456789012:role/HCPTerraform-prod
└── Audience:    aws.workload.identity

In AWS, an IAM identity provider is configured for app.terraform.io and the role's trust policy allows AssumeRoleWithWebIdentity from that issuer, scoped to the specific HCP Terraform organization/workspace. The same pattern works for Azure (federated credentials), GCP (Workload Identity Federation), and Vault.

Result: zero long-lived cloud credentials in HCP Terraform. The exam tests both the concept (no static creds) and the primitives (OIDC token → AssumeRoleWithWebIdentity).

HCP Terraform Projects (004 objective)

Projects are the organizational layer above workspaces. A project groups related workspaces (e.g., all fastapi-* workspaces) and is the unit at which:

  • Team permissions are granted (read/plan/write/admin on the entire project at once)
  • Variable sets can be auto-applied (a variable set scoped to a project applies to every workspace in it)
  • Policy sets can be attached (Sentinel/OPA policies enforced across all project workspaces)

Without projects, every workspace must be permissioned and configured individually — unmanageable past ~10 workspaces.

Change Requests (004 objective)

Change Requests add an explicit approval workflow on top of plans. Where a normal HCP Terraform run goes plan → approval → apply, a workspace with Change Requests enabled requires a separately-tracked change request to be opened, reviewed, and approved before the run can apply. The change request carries metadata (description, ticket link, reviewers) that becomes part of the audit trail.

Useful for environments where infrastructure changes need to be tracked alongside organizational change-management processes (ITIL, SOX-style controls).

Health: Drift Detection and Continuous Validation (004 objective)

Two features grouped under "Health" in the HCP Terraform UI:

  • Drift Detection — a periodic refresh-only run that compares state against real infrastructure. When drift appears (someone clicked in the AWS console), the workspace surfaces an alert.
  • Continuous Validation — periodic re-evaluation of check block assertions (see Domain 8). When an assertion starts failing (e.g., the ALB health endpoint stops returning 200), the workspace surfaces a warning.

Both are Plus tier features in HCP Terraform.

Variable Sets and Run Triggers (004 objective)

  • Variable Sets — reusable collections of variables (Terraform vars + env vars) that can be attached to multiple workspaces or to a project. Common pattern: one variable set per AWS account containing the shared region/role configuration.
  • Run Triggers — cause a run in workspace B to be queued automatically when workspace A finishes a successful apply. Used to chain networkingalbasg workspaces. Triggers queue a normal run (still subject to approval if auto-apply is disabled) — they do not bypass policy or approval gates.

Workspace execution modes (recap)

Non-Cloud Providers (Video 75)

Terraform providers are not limited to cloud infrastructure. Any API can have a provider.

random provider — generating unique values

terraform {
  required_providers {
    random = { source = "hashicorp/random", version = "~> 3.0" }
  }
}

# Random string for bucket name uniqueness
resource "random_string" "suffix" {
  length  = 8
  special = false
  upper   = false
}
resource "aws_s3_bucket" "logs" {
  bucket = "fastapi-logs-${random_string.suffix.result}"
}

# Random password for RDS
resource "random_password" "db" {
  length           = 24
  special          = true
  override_special = "!#$%&*()-_=+[]{}<>:?"
}
resource "aws_db_instance" "main" {
  password = random_password.db.result
}

# Random ID for unique identifiers
resource "random_id" "server" {
  byte_length = 4
}
# random_id.server.hex → "8b45a2c1"
# random_id.server.dec → "2337079745"

Exam note: random_* resources are created once and stored in state. They do not change on subsequent terraform apply calls — unless you explicitly use keepers to trigger regeneration when a dependency changes.

resource "random_password" "db" {
  length = 24
  keepers = {
    db_identifier = var.db_identifier   # regenerate if db_identifier changes
  }
}

local provider — managing local files

resource "local_file" "config" {
  content  = templatefile("config.tpl", { endpoint = aws_lb.web.dns_name })
  filename = "${path.module}/generated/config.json"
}

resource "local_sensitive_file" "private_key" {
  content         = tls_private_key.ssh.private_key_pem
  filename        = "${path.module}/keys/id_rsa"
  file_permission = "0600"
}

null provider — triggers and side effects

resource "null_resource" "db_migration" {
  # Re-run whenever the image_tag changes
  triggers = {
    image_tag = var.image_tag
  }

  provisioner "local-exec" {
    command = "python3 scripts/run_migrations.py --host ${aws_db_instance.main.endpoint}"
  }
}

null_resource has no real-world counterpart. It exists solely to run provisioners (local-exec, remote-exec) and to create explicit dependencies that express ordering without a resource dependency.

http provider — reading external data

data "http" "my_ip" {
  url = "https://ifconfig.me"
}

resource "aws_security_group_rule" "allow_my_ip" {
  cidr_blocks = ["${data.http.my_ip.response_body}/32"]
  # ... rest of rule
}

tls provider — certificate and key generation

resource "tls_private_key" "ssh" {
  algorithm = "RSA"
  rsa_bits  = 4096
}

resource "aws_key_pair" "deployer" {
  key_name   = "deployer-key"
  public_key = tls_private_key.ssh.public_key_openssh
}

# Store private key in Secrets Manager
resource "aws_secretsmanager_secret_version" "ssh_key" {
  secret_id     = aws_secretsmanager_secret.ssh.id
  secret_string = tls_private_key.ssh.private_key_pem
}

Warning: private keys generated by tls_private_key are stored in the state file in plaintext. Always use an encrypted remote backend — S3 with encrypt = true (SSE-S3 or SSE-KMS) and bucket policies, or HCP Terraform, which encrypts state at rest by default.

Terraform Autocomplete (Video 76)

# Install tab completion for the current shell (bash or zsh)
terraform -install-autocomplete

# Restart your shell or source the profile
source ~/.bashrc    # bash
source ~/.zshrc     # zsh

After installation, pressing Tab after terraform completes:

  • Subcommands: apply, plan, init, destroy, state, etc.
  • Flags: -var, -var-file, -target, -out, etc.
  • Resource addresses for -target and state commands (reads current state)

To uninstall:

terraform -uninstall-autocomplete

Multi-Region Provider Aliasing (Video 77, Day 14)

The complete pattern with all three use cases:

# 1. Multi-region deployment
provider "aws" { region = "us-east-1" }
provider "aws" { alias = "eu"; region = "eu-west-1" }

# 2. ACM for CloudFront must always be us-east-1 — even if primary region is different
provider "aws" { alias = "us_east_1"; region = "us-east-1" }
resource "aws_acm_certificate" "cdn" {
  provider    = aws.us_east_1
  domain_name = "api.example.com"
}

# 3. Multi-account with assume_role
provider "aws" {
  alias  = "prod"
  region = "us-east-1"
  assume_role {
    role_arn = "arn:aws:iam::PROD_ACCOUNT_ID:role/TerraformDeployRole"
  }
}

Exam rule: a provider block without alias is the default. A provider block with alias must be explicitly referenced by resources (provider = aws.eu) or passed to modules (providers = { aws = aws.eu }).

Common Exam Traps

Trap The answer
Can variables be used in a backend block? No — backend blocks only accept literal values
Does terraform refresh update real infrastructure? No — it only updates the state file to match real infra. Real resources are unchanged.
What does sensitive = true do to state? Nothing — the value is still stored plaintext in state. It only redacts terminal output.
When does count = 0 vs for_each = {} differ? count = 0 removes all instances; for_each = {} with an empty map does the same. Both result in no resources.
What happens when you remove the middle item from a count list? All subsequent items are renumbered and recreated. Use for_each instead.
CLI terraform workspace and HCP Terraform workspaces — related? No — completely separate concepts that share a name
terraform taint — current behavior? Deprecated since 0.15.2. Use terraform apply -replace=<address> instead.
Does terraform plan modify infrastructure? No — plan is read-only. It reads current state and the provider APIs, but makes no changes.
What must exist before terraform init with an S3 backend? The S3 bucket and DynamoDB table must be created manually first.
terraform destroy -target — does it destroy dependencies? No — it only destroys the targeted resource. Its dependents may be left in a broken state.
What does ignore_changes = all do? Ignores all attribute changes — the resource is never updated by Terraform after creation.
Can a data source create resources? No — data sources are read-only.
Can for_each keys be unknown at plan time? Nofor_each requires keys known during planning. You cannot use aws_instance.web[*].id (computed at apply) as the key set. Use a static map or toset of known values.
Does prevent_destroy block terraform destroy? Yesprevent_destroy = true aborts any destroy of that resource, including a full terraform destroy. Remove the lifecycle setting (and apply) before destroying.
Is -target for routine use? No — it is a break-glass tool. HashiCorp explicitly warns it can leave state in a partially-applied condition. Routine workflows should plan/apply the whole config.
What can terraform_remote_state read? Only the output values of the referenced state. It cannot read arbitrary resource attributes — if you need a value cross-workspace, expose it as an output in the source workspace.
Difference between precondition/postcondition and check block? Pre/postconditions abort plan or apply on failure. check blocks emit a warning only — useful for ongoing health monitoring without breaking deploys.
Difference between moved block and terraform state mv? Both rename/move a resource in state without recreating it. moved is declarative (committed to the repo, applies for everyone); state mv is imperative (one local operation). Prefer moved for team workflows.
Does the removed block delete the real infrastructure? Only if lifecycle { destroy = true } is set. With destroy = false, the resource is removed from state only — the real AWS resource is left alone.
Does Vault provider keep the secret out of state? No. Vault keeps the secret out of .tf files and VCS, but the value fetched by the data source is still written into the state file. State encryption is still required.
What replaces static AWS keys in HCP Terraform? Dynamic Provider Credentials — OIDC token exchanged for AssumeRoleWithWebIdentity at run time. No long-lived keys stored as workspace variables.

Quick Command Reference

# Initialization
terraform init                    # initialize
terraform init -upgrade           # upgrade providers within constraints
terraform init -reconfigure       # reinitialize with new backend config

# Validation
terraform fmt -recursive          # format
terraform fmt -recursive -check   # CI check
terraform validate                # validate config

# Planning
terraform plan                    # show changes
terraform plan -out=tfplan        # save plan
terraform plan -destroy           # show what destroy would do
terraform plan -refresh-only      # show drift without applying

# Applying
terraform apply                   # plan + apply
terraform apply tfplan            # apply saved plan
terraform apply -auto-approve     # no prompt
terraform apply -replace=<addr>   # force-replace one resource

# Destroying
terraform destroy                 # destroy all
terraform destroy -target=<addr>  # destroy one resource

# State
terraform state list              # list resources
terraform state show <addr>       # show resource attributes
terraform state mv <src> <dst>    # rename/move resource in state
terraform state rm <addr>         # remove from state (not from AWS)
terraform import <addr> <id>      # add existing resource to state

# Workspace
terraform workspace list          # list workspaces
terraform workspace new <name>    # create workspace
terraform workspace select <name> # switch workspace
terraform workspace delete <name> # delete workspace

# Output
terraform output                  # all outputs
terraform output -raw <name>      # single output, no quotes
terraform output -json            # all outputs as JSON

# Utilities
terraform console                 # interactive expression evaluator
terraform show                    # human-readable current state
terraform show tfplan             # human-readable saved plan
terraform graph | dot -Tsvg > graph.svg   # dependency graph
terraform -install-autocomplete   # install shell tab completion
terraform version                 # show Terraform and provider versions
terraform providers               # show providers used in current config
terraform login                   # authenticate to HCP Terraform
terraform logout                  # remove stored credentials

Study Plan Template

HashiCorp does not publish per-domain weightings for 004, so prioritize by breadth of objectives in each domain rather than by percentages. Domains 4 and 8 (Configuration and HCP Terraform) have the most sub-objectives and the most 004-specific additions — invest the most time there.

Priority 004 Domain Why Days in series
1 4 — Terraform configuration Largest surface: variables, outputs, expressions, complex types, custom conditions, check blocks, Vault Days 10–11, 18
2 8 — HCP Terraform Substantially expanded in 004: Projects, Change Requests, Dynamic Credentials, OPA, Health Days 19–22
3 6 — Terraform state management New moved and removed blocks; drift; backends Days 5, 9, 17
4 5 — Terraform modules Sources, versioning, provider passing, registry vs private Days 9, 14–15
5 3 — Core Terraform workflow init/validate/plan/apply/destroy/fmt — muscle memory Days 1–5, 17
6 2 — Terraform fundamentals Provider installation, lock file, multi-provider configs Days 1–3, 14
7 7 — Maintain infrastructure Import (CLI + import {} block), state inspection, TF_LOG Days 17, 23
8 1 — IaC with Terraform Conceptual; quick review only Day 1

High-value review areas (frequently tested, easy to confuse):

  • Variable precedence order (7 sources)
  • count vs for_each — when each breaks and why
  • sensitive = true — what it does and does not do (and the same caveat for Vault data sources)
  • CLI workspaces vs HCP Terraform workspaces vs HCP Terraform Projects
  • terraform refresh deprecation → plan -refresh-only (inspect) and apply -refresh-only (persist)
  • Backend block: no variables allowed
  • ~> constraint operator behavior with patch vs minor versions
  • taint deprecation → -replace
  • terraform import — CLI form and the declarative import {} block (1.5+)
  • moved vs removed blocks (1.1+ / 1.7+) and how they differ from terraform state mv / state rm
  • precondition / postcondition (abort) vs check block (warn only)
  • Sentinel vs OPA — same enforcement levels, different language; both attach as policy sets
  • Dynamic Provider Credentials vs static workspace env vars

Where I Am At

Day 23 is the inflection point of the series — the previous 22 days built a real production stack, and from here the focus shifts to consolidation, certification, and the remaining hands-on gaps (the private networking module, advanced patterns, real-project application). The cheat sheet above is genuinely a cheat sheet: every entry maps back to a day where the concept was used in a working example, not just defined in isolation.

If the goal is the Terraform Associate (004) certification, the highest-leverage prep is sitting the official HashiCorp 004 sample questions twice through, then re-reading this post focusing on the Common Exam Traps table and the new 004 topics: check blocks, moved/removed blocks, the Vault provider caveat, and the expanded HCP Terraform domain (Projects, Change Requests, Dynamic Credentials, OPA). Those are the areas most likely to differ from older 003-era study material that may still be circulating online.


This post is part of a 30-day Terraform learning journey.

Share This Article

Did you find this helpful?

💬 Comments

No comments yet. Be the first to share your thoughts!

Leave a Comment

Get In Touch

I'm always open to discussing new projects and opportunities.

Location Yassa/Douala, Cameroon
Availability Open for opportunities

Connect With Me

Send a Message

Have a project in mind? Let's talk about it.