Last reviewed: May 2026
Build the AWS services on the TF-PRO exam with plain Terraform — one block at a time, each tied back to an exam domain. The same code works on OpenTofu.
By the end of this lab you'll have exercised the four domains the Terraform Pro exam weights most: authoring advanced HCL, building and composing modules, performing state surgery, and operating against HCP Terraform — all with the credential-free random and local providers, so there's nothing to pay for and nothing to clean up but a folder.
You'll model inputs with rich type constraints and validation, transform collections with the expressions and functions the exam leans on, author a reusable module and fan it out with for_each, then refactor live state with import, moved, and removed blocks. The final step wires the whole thing to HCP Terraform for remote execution. Every snippet is plain Terraform — identical on OpenTofu. Build it up in a single working directory; we call out the files that live under ./modules.
>= 1.9 or OpenTofu >= 1.8 (terraform version). We use removed blocks (1.7+) and import blocks (1.5+), so a current release matters more here than in the Associate lab.random and local run entirely on your machine.mkdir tf-pro-lab && cd tf-pro-lab).terraform fmt and terraform validate after every edit — the Pro exam assumes you treat them as part of authoring, not an afterthought.This lab is completely free. The random and local providers create only small files on your own disk plus a local terraform.tfstate; nothing is provisioned in any cloud and nothing bills while idle. Step 7 (HCP Terraform) runs on the free tier, which covers a single lab workspace and the operational features the exam asks about.
We start the HCL and Configuration domain by pinning a recent Terraform — the Pro exam assumes language features that only exist in newer releases (optional() object attributes, removed blocks). The required_providers block locks random and local, and terraform init writes a .terraform.lock.hcl you should commit so plans are reproducible across a team.
The two habits to build now carry through the whole lab: terraform fmt canonicalizes spacing and alignment (the exam tests that it rewrites files in place and exits non-zero in -check mode), and terraform validate checks that your configuration is internally consistent before any provider is contacted. With the toolchain pinned and those two commands at your fingertips, we can write HCL that's worth validating.
terraform {
required_version = ">= 1.9"
required_providers {
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
# Build the muscle memory:
# terraform fmt # rewrite files to canonical style
# terraform validate # check internal consistency, offlineThe Pro exam goes well past type = string. Here we declare an object type with optional() attributes that supply defaults when a caller omits them, then attach multiple validation blocks — the exam tests that a variable can carry more than one, each with its own condition and error_message.
The try() in locals is the other Pro-level move: it returns the first expression that evaluates without error, so reading a maybe-absent attribute degrades gracefully instead of halting the plan. Its sibling can() (which returns a bool) appears later in Step 4's validation. This platform variable becomes the single source of truth for the rest of the lab — every later step reads from it, so getting the type and its guarantees right here pays off downstream.
variable "platform" {
description = "Platform configuration."
type = object({
name = string
replicas = optional(number, 2)
features = optional(set(string), [])
owners = list(string)
})
validation {
condition = var.platform.replicas >= 1 && var.platform.replicas <= 10
error_message = "replicas must be between 1 and 10."
}
validation {
condition = length(var.platform.owners) > 0
error_message = "At least one owner is required."
}
default = {
name = "lab"
owners = ["platform@example.com"]
}
}
locals {
# try() returns the first error-free expression - here it shields
# against features being unset and classifies the tier.
tier = length(try(var.platform.features, [])) > 0 ? "enhanced" : "standard"
}Collection gymnastics are the densest part of the HCL and Configuration domain. We use setproduct() to build every region/service pair, then a for expression to reshape those pairs into a keyed map — the exact pattern that later feeds a for_each. Alongside it, flatten() + distinct() collapse a list-of-lists of owners into a clean unique set.
These are the functions Pro questions return to again and again: for with a => to produce a map, setproduct for cross-products, flatten to drop one level of nesting, merge to combine maps, and jsonencode to serialize the result. We render the computed deployments map to a file so you can cat out/deployments.json and see the shape your expression produced. That keyed map is precisely the kind of value we'll hand to a module's for_each next.
locals {
regions = ["us-east-1", "eu-west-1"]
services = ["api", "web"]
# setproduct builds every (region, service) pair; the for
# expression reshapes the pairs into a keyed map.
deployments = {
for pair in setproduct(local.regions, local.services) :
"${pair[0]}/${pair[1]}" => {
region = pair[0]
service = pair[1]
}
}
# flatten + distinct collapse nested owner lists into a unique set.
all_owners = distinct(flatten([
for owner in var.platform.owners : split(",", owner)
]))
}
resource "local_file" "matrix" {
filename = "${path.module}/out/deployments.json"
content = jsonencode(local.deployments)
}Now the Modules domain (25%). We extract a workload child module under ./modules/workload with its own required_providers, validated inputs, and published outputs. The can(regex(...)) in the name validation is the Pro idiom: regex would raise on a non-match, and can turns that raise into a false the condition can use.
A well-built module hides its internals and exposes a stable contract — callers pass name and replicas, and get back an id and a manifest_path, never touching the random_string inside. The module declaring its own provider requirements is a deliberate Pro point: provider configuration is inherited from the root by default, but provider requirements are declared per module. With a clean module in hand, the interesting question is how to instantiate it many times and wire the instances together.
# modules/workload/main.tf
terraform {
required_providers {
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
variable "name" {
type = string
validation {
# can() turns regex's raise-on-no-match into a usable bool.
condition = can(regex("^[a-z][a-z0-9-]{1,30}$", var.name))
error_message = "name must be 2-31 chars: lowercase letter, then letters/digits/hyphens."
}
}
variable "replicas" {
type = number
default = 1
}
resource "random_string" "suffix" {
length = 6
special = false
upper = false
}
resource "local_file" "manifest" {
filename = "${path.root}/out/${var.name}.json"
content = jsonencode({
name = var.name
replicas = var.replicas
id = "${var.name}-${random_string.suffix.result}"
})
}
output "id" {
description = "Stable identifier for this workload."
value = "${var.name}-${random_string.suffix.result}"
}
output "manifest_path" {
value = local_file.manifest.filename
}Still in the Modules domain, we now call module.workload once per workload with for_each over a set, set replicas conditionally per key, and then consume every instance's id output into a single aggregate registry.json. Iterating over module.workload with a for expression is the canonical way the exam wires one module's outputs into another resource's inputs.
The explicit depends_on on the aggregate file is a teaching point: implicit dependencies from the module.workload reference already order things correctly, but depends_on makes the intent unmissable and is occasionally required when a dependency isn't expressed through data. Run terraform init after adding the module source, then apply. With several module instances live and their outputs aggregated, we have real state worth operating on — which is the next domain.
# main.tf (root) - fan the module out, then aggregate its outputs
module "workload" {
source = "./modules/workload"
for_each = toset(["api", "web", "worker"])
name = each.key
replicas = each.key == "api" ? 3 : 1
}
resource "local_file" "registry" {
filename = "${path.module}/out/registry.json"
content = jsonencode({
for k, m in module.workload : k => m.id
})
depends_on = [module.workload]
}
output "workload_ids" {
value = { for k, m in module.workload : k => m.id }
}The CLI and State Management domain (25%) is where the Pro exam separates itself from the Associate. We use three config-driven state operations. An import block adopts an object that already exists into state — we target a built-in terraform_data so the example stays credential-free. A removed block drops a resource from state without destroying the real object — exactly what you reach for when handing ownership to another configuration. And terraform_data with replace_triggered_by forces a downstream replacement whenever the registry from Step 5 changes, with no provider involved.
Know the imperative CLI equivalents the exam still tests too: terraform state list and state show to inspect, terraform state mv to rename, terraform state rm to forget, terraform plan -target=ADDR to narrow a run, and terraform apply -replace=ADDR to force one resource to be recreated. With state firmly under your control locally, the last domain is doing all of this on HCP Terraform.
# 1) Adopt an existing object into state (Terraform 1.5+),
# replacing the older imperative "terraform import" command.
import {
to = terraform_data.legacy
id = "existing-id"
}
resource "terraform_data" "legacy" {}
# 2) Stop managing a resource WITHOUT destroying it (Terraform 1.7+).
removed {
from = random_string.deprecated
lifecycle {
destroy = false
}
}
# 3) terraform_data + replace_triggered_by recreates a marker
# whenever the registry file content changes.
resource "terraform_data" "deploy_marker" {
input = local_file.registry.content
lifecycle {
replace_triggered_by = [local_file.registry]
}
}The HCP Terraform Operations domain (20%) closes the exam. A cloud block — here using workspaces { tags = [...] } so one configuration can map to many tag-matched workspaces — moves execution off your laptop. Runs happen on HCP Terraform with remote, locked state; the exam contrasts the remote, local, and agent execution modes and the VCS-driven, CLI-driven, and API-driven run workflows.
This is also where the operational features the Pro exam names come together: variable sets to share inputs across workspaces, run triggers to chain one workspace's apply into another's plan, run tasks for external integrations, policy sets (Sentinel or OPA) to gate applies, and a private module registry to publish the kind of module you built in Step 4. Swap my-org for your organization, run terraform login, then terraform init to migrate state. The configuration you wrote across Steps 1–6 runs unchanged — only the place and the guardrails around it have leveled up, which is the entire Pro story.
terraform {
cloud {
organization = "my-org"
workspaces {
tags = ["terraform-pro-lab"]
}
}
}
# Authenticate once, then migrate state to the remote workspace:
# terraform login
# terraform initEverything is local, so teardown is fast:
terraform destroy to remove the generated files and clear them from state. Because of the removed block in Step 6, random_string.deprecated (if it ever existed) is forgotten rather than destroyed — that's intended.terraform destroy against the remote workspace, then delete the terraform-pro-lab workspace in the UI and remove the cloud block.cd .. && rm -rf tf-pro-lab. The .terraform/ cache, .terraform.lock.hcl, and any local state go with it.The Pro exam tests Terraform authoring and operations, not a particular cloud, so this lab provisions no cloud infrastructure on purpose — that keeps it credential-free and lets every step concentrate on HCL, modules, state, and HCP Terraform.
A handful of Pro-relevant topics are better studied than provisioned here: dynamic blocks (which need a resource type with repeatable nested blocks — a cloud resource — to be meaningful), provider configuration_aliases and passing aliased providers into modules, Sentinel / OPA policy authoring, and the private module registry publishing flow. The Browse and Playbook sections of this cert page cover those conceptually. The hands-on value here is the authoring-and-operations loop the exam is built around: validated complex inputs, composed modules, fearless state surgery, and remote runs.