Skip to content

Terraform Exam Gaps — HCP Terraform, Functions, State, Provisioners


1. Version Constraints

You'll see these everywhere — providers, modules, Terraform itself. You need to know exactly what each operator means.

version = "= 5.0.0"    # exactly this version, nothing else
version = ">= 5.0.0"   # this version or higher, no upper limit
version = "<= 5.0.0"   # this version or lower
version = "~> 5.0"     # >= 5.0 AND < 6.0  (locks major version)
version = "~> 5.0.0"   # >= 5.0.0 AND < 5.1.0 (locks minor version)
version = "!= 5.1.0"   # anything except this specific version

The one that trips people up: - ~> 5.0 allows 5.1, 5.9 but NOT 6.0 — locks the leftmost specified digit - ~> 5.0.0 allows 5.0.1, 5.0.9 but NOT 5.1.0 — more restrictive - In modules, always pin with ~> to allow patches but block breaking changes


2. Terraform Functions

The exam gives you scenarios where you need to pick the right function. You don't need to memorise syntax perfectly — know what each one does.

String functions

lower("HELLO")               # "hello"
upper("hello")               # "HELLO"
trimspace("  hello  ")       # "hello"
replace("hello world", "world", "terraform")  # "hello terraform"
split(",", "a,b,c")          # ["a", "b", "c"]
join("-", ["a", "b", "c"])   # "a-b-c"
format("Hello, %s!", "Mark") # "Hello, Mark!"

Collection functions

length(["a", "b", "c"])      # 3 — works on lists, maps, strings
toset(["a", "b", "a"])       # ["a", "b"]  removes duplicates, converts to set
tolist(toset(["b", "a"]))    # ["a", "b"]  back to list (sorted)
merge({a=1}, {b=2})          # {a=1, b=2} — combine two maps
lookup({a="foo"}, "a", "default")  # "foo"  get map value, with fallback default
keys({a=1, b=2})             # ["a", "b"]
values({a=1, b=2})           # [1, 2]
contains(["a","b"], "a")     # true — check if list contains a value
flatten([["a","b"],["c"]])   # ["a","b","c"]  collapses nested lists

Numeric functions

max(1, 2, 3)    # 3
min(1, 2, 3)    # 1
ceil(1.2)       # 2 — round up
floor(1.9)      # 1 — round down

Type conversion

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

The ones the exam actually tests most:

  • length() — almost always shows up
  • toset() — used with for_each to deduplicate
  • lookup() — map access with fallback
  • merge() — combining tag maps
  • join() / split() — string manipulation

3. State Commands — Full Picture

The exam tests what each state command does and when to use it.

# List all resources Terraform is tracking
terraform state list

# Show details of one resource
terraform state show aws_instance.web

# Move a resource to a new address (rename without destroying)
# Use when you rename a resource in code — without this Terraform
# would destroy the old and create a new one
terraform state mv aws_instance.old_name aws_instance.new_name

# Remove a resource from state without destroying the real infrastructure
# Use when you want Terraform to stop managing something
terraform state rm aws_instance.web

# Pull remote state and display it locally
terraform state pull

# Push local state to remote backend (use with extreme caution)
terraform state push

# Import existing real infrastructure into state
# The resource must already exist in your .tf file
terraform import aws_instance.web i-1234567890abcdef0

# Refresh state to match real-world infrastructure
# Updates state to reflect any changes made outside Terraform
terraform refresh

The moved block — replaces state mv in modern Terraform:

# In your .tf file, when you rename a resource
moved {
  from = aws_instance.old_name
  to   = aws_instance.new_name
}
- Cleaner than terraform state mv because it's tracked in code - Terraform executes it on next apply and then you can remove the block - Exam tests awareness that this exists as the modern alternative

The removed block — replaces state rm in modern Terraform:

removed {
  from = aws_instance.web

  lifecycle {
    destroy = false   # remove from state only, don't destroy real resource
  }
}


4. Provisioners

Provisioners run scripts on a resource after creation. HashiCorp considers them a last resort — use them only when no provider resource exists for what you need to do. The exam tests what they are, not that you should use them.

resource "aws_instance" "web" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t2.micro"

  # local-exec: runs a command on the machine running Terraform (your laptop/CI)
  provisioner "local-exec" {
    command = "echo ${self.public_ip} >> inventory.txt"
    # self = reference to the resource this provisioner is inside
  }

  # remote-exec: runs a command on the remote resource (the EC2 instance)
  provisioner "remote-exec" {
    inline = [
      "sudo apt-get update",
      "sudo apt-get install -y nginx",
    ]

    connection {
      type        = "ssh"
      user        = "ubuntu"
      private_key = file("~/.ssh/id_rsa")  # file() reads a local file
      host        = self.public_ip
    }
  }

  # destroy-time provisioner: runs when resource is destroyed
  provisioner "local-exec" {
    when    = destroy
    command = "echo 'destroying ${self.id}'"
  }
}

Key points: - local-exec — runs locally on the Terraform machine - remote-exec — runs on the remote resource, needs a connection block - when = destroy — runs on destroy instead of create - on_failure = continue — if provisioner fails, continue instead of erroring - If a provisioner fails, the resource is marked as tainted — next plan will destroy and recreate it - self — refers to the resource the provisioner is inside


5. HCP Terraform (formerly Terraform Cloud)

The exam has a meaningful chunk on HCP Terraform. Know the concepts — you won't be logging into a UI during the exam.

What it is

HCP Terraform is HashiCorp's managed service for running Terraform remotely. Instead of running terraform apply on your laptop, it runs in HCP Terraform's infrastructure. State is stored and managed automatically.

Workspaces in HCP Terraform vs CLI

CLI workspaces:
- Just separate state files within the same config
- Simple dev/staging/prod separation
- Same backend, different state key

HCP Terraform workspaces:
- Full isolated environments with their own state, variables, permissions
- Can be linked to a VCS repo (GitHub, GitLab)
- Has run history, approval workflows, notifications
- These are NOT the same thing as CLI workspaces

Remote backend config for HCP Terraform

terraform {
  cloud {                              # modern way — replaces backend "remote"
    organization = "my-org"           # your HCP Terraform organisation name

    workspaces {
      name = "my-workspace"           # specific workspace
      # OR
      tags = ["production"]           # all workspaces with this tag
    }
  }
}

Run types

Speculative plan:  read-only plan, no apply. Used in PRs to preview changes.
Apply run:         full plan + apply. Can be auto-approved or require manual approval.
Destroy run:       plans and applies a destroy.

Variables in HCP Terraform

  • Terraform variables — same as normal variable {} blocks, set per workspace
  • Environment variables — set in the workspace, injected into the run environment
  • Sensitive variables — marked sensitive, never shown in UI or logs
  • Variables set in HCP Terraform override terraform.tfvars

VCS-driven workflow

  1. Connect workspace to a GitHub repo
  2. Every PR triggers a speculative plan — shows what would change
  3. Merge to main triggers an apply run
  4. Apply can be auto-approved or require a human to click confirm in the UI

Sentinel (Policy as Code)

  • Sentinel is HCP Terraform's policy enforcement layer
  • Policies run between plan and apply — they can block an apply if rules are violated
  • Example policy: "no EC2 instance can be larger than t3.large"
  • Three enforcement levels:
  • advisory — warns but allows apply to proceed
  • soft-mandatory — blocks apply, but an admin can override
  • hard-mandatory — always blocks, no override possible
  • You won't write Sentinel policies in the exam — just know what it is and the enforcement levels

6. Tainted Resources

A tainted resource is one Terraform will destroy and recreate on next apply. Happens automatically when a provisioner fails. Can also be triggered manually.

# Mark a resource as tainted manually
# Forces destroy + recreate on next apply
terraform taint aws_instance.web

# Remove the taint (change your mind)
terraform untaint aws_instance.web

Modern alternative — -replace flag:

# Preferred over taint in Terraform 0.15.2+
terraform apply -replace="aws_instance.web"
- Does the same thing as taint but in one step - Exam tests awareness of both — know that -replace is the modern way


7. Sensitive Values

Sensitive data in Terraform — how to handle it, what the exam tests.

variable "db_password" {
  type      = string
  sensitive = true      # hides value in plan/apply output
}

output "db_password_out" {
  value     = var.db_password
  sensitive = true      # required if outputting a sensitive variable
                        # Terraform will error if you forget this
}

Important behaviours: - Sensitive values are still stored in state in plain text - sensitive = true only hides from terminal output — not from state file - This is why state must be encrypted and access-controlled - Environment variables for secrets: export TF_VAR_db_password="secret" — Terraform auto-reads TF_VAR_* variables


8. The .terraform.lock.hcl File

Locks provider versions so everyone on the team uses the same provider. Commit this to git — it's the equivalent of package-lock.json.

# .terraform.lock.hcl — auto-generated, don't edit manually
provider "registry.terraform.io/hashicorp/aws" {
  version     = "5.31.0"
  constraints = "~> 5.0"
  hashes = [
    "h1:abcdef...",   # cryptographic hash to verify the download
  ]
}

Key points: - Generated by terraform init - Commit to git — ensures everyone uses identical provider versions - Update with terraform init -upgrade - If you don't commit it, two people running terraform init at different times might get different provider versions


9. Terraform Graph and Dependency

Terraform builds a dependency graph automatically. Explicit dependencies via depends_on when the graph can't figure it out.

resource "aws_s3_bucket" "data" {
  bucket = "my-data-bucket"
}

resource "aws_instance" "web" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t2.micro"

  # Implicit dependency — Terraform sees the reference and creates the bucket first
  # user_data references the bucket, so bucket is created before instance
  user_data = "bucket=${aws_s3_bucket.data.bucket}"
}

resource "aws_instance" "app" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t2.micro"

  # Explicit dependency — no reference exists but app needs web running first
  # Terraform can't infer this, so you tell it manually
  depends_on = [aws_instance.web]
}
# Output the dependency graph in DOT format
terraform graph

# Pipe to graphviz to visualise (not on exam, just awareness)
terraform graph | dot -Tsvg > graph.svg

Exam Cheat Reference — Gaps Covered

Concept What to remember
~> 5.0 >= 5.0, < 6.0 — locks major version
~> 5.0.0 >= 5.0.0, < 5.1.0 — locks minor version
length() count items in list/map/string
toset() deduplicate + convert to set, use with for_each
lookup() get map value with a fallback default
merge() combine two maps into one
state mv rename resource without destroy/recreate
moved {} block modern code-based alternative to state mv
terraform import bring existing infra under Terraform management
terraform refresh update state to match real world
local-exec provisioner that runs on the Terraform machine
remote-exec provisioner that runs on the remote resource
taint / -replace force destroy + recreate, -replace is modern
HCP Terraform workspace full isolated env, NOT same as CLI workspace
Sentinel advisory warns, doesn't block
Sentinel soft-mandatory blocks, admin can override
Sentinel hard-mandatory always blocks, no override
sensitive = true hides from output only, still in state plain text
TF_VAR_name env var pattern to pass variable values
.terraform.lock.hcl locks provider versions, commit to git
depends_on explicit dependency when no reference exists