Last reviewed: May 2026
Build the AWS services on the 004 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 run the full core Terraform workflow end to end and touched every Associate exam domain — without a cloud account or a single cent of spend. We use the random and local providers, which need no credentials, so the focus stays on the thing the exam actually tests: how Terraform itself behaves.
You'll write your first resource, parameterize it with variables and outputs, generate many resources with for_each, refactor the repetition into a reusable module, inspect and migrate state, and finally point the configuration at HCP Terraform for remote runs. Every snippet is plain Terraform — the same code works unmodified on OpenTofu. Drop the blocks into a single main.tf (we'll call out the few that live in their own files), run terraform init once, then terraform apply step by step.
>= 1.5 or OpenTofu >= 1.6 on your PATH (terraform version). We use config-driven import blocks in Step 6, which need 1.5+.random and local providers run entirely on your machine.mkdir tf-associate-lab && cd tf-associate-lab).This lab is completely free. The random and local providers create no cloud resources — only a few small files on your own disk and entries in a local terraform.tfstate. Step 7 (HCP Terraform) uses the free tier, which is ample for a single lab workspace. There is nothing here that bills while idle.
Before anything runs, we declare which Terraform version we expect and which providers we depend on. Pinning is an exam favorite — the Terraform Basics and Core Workflow domains both test that you understand required_version, the required_providers block, and the role of terraform init in downloading provider plugins into .terraform/.
We deliberately pick random and local. Neither needs a cloud login, so the whole lab stays free and reproducible, and the exam never asks about a specific cloud anyway — it asks about Terraform. Drop this into a fresh main.tf and run terraform init; you'll watch Terraform resolve and lock both providers into a .terraform.lock.hcl file, which is itself a Basics-domain talking point (commit the lock file so every run uses identical plugin versions).
terraform {
required_version = ">= 1.5"
required_providers {
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
# Both providers run locally and need no "provider" configuration
# block at all - a useful reminder that providers are just plugins.Now we exercise the heart of the Use the Core Terraform Workflow domain (18% of the exam). We declare a random_pet that invents a friendly name, then a local_file that writes that name to disk. The local_file.greeting resource references random_pet.name.id, and that reference is what tells Terraform the file depends on the pet — implicit dependency ordering, no depends_on needed.
Run the workflow in order: terraform plan shows you a diff of what will change before anything happens, and terraform apply makes it real and records the result in terraform.tfstate. Run terraform apply a second time without changing anything and you'll see No changes — that's idempotency, and the exam loves to ask why a second apply is a no-op. With a working resource and a fresh state file in hand, we can start making the configuration flexible.
resource "random_pet" "name" {
length = 2
separator = "-"
}
resource "local_file" "greeting" {
filename = "${path.module}/hello.txt"
content = "Hello from ${random_pet.name.id}!\n"
}Hard-coded values are fine for a demo but the Read, Generate, and Modify Configuration domain (19%) expects you to parameterize. We add an input variable with a type, a default, and a validation block that rejects anything outside our allowed environments — validation runs at plan time and is a frequent exam question. We compute a locals map once and reuse it, and we expose results with output blocks so other configurations (and terraform output) can read them.
Notice the layering the exam tests: variable is input, locals is a derived/intermediate value, and output is the published result. Apply again and try terraform output pet_name to read a single value, or terraform output -json to script against it. With inputs and outputs wired up, we're ready to stop writing one resource at a time and generate a whole set.
variable "environment" {
description = "Deployment environment label."
type = string
default = "dev"
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be one of: dev, staging, prod."
}
}
locals {
common_tags = {
environment = var.environment
managed_by = "terraform"
}
}
output "pet_name" {
description = "The generated pet name."
value = random_pet.name.id
}
output "tags" {
value = local.common_tags
}Real configurations rarely declare one of everything. Here we lean further into the Read, Generate, and Modify Configuration domain by driving resource creation from a collection. A set(string) variable lists logical services; for_each then stamps out one random_string and one local_file per service, addressable as random_string.suffix["api"] and so on.
This step also shows off expressions and built-in functions the exam expects you to recognize: each.key for the current element, string interpolation to build a per-service bucket name, and jsonencode() to turn an HCL object into a JSON file on disk. We reuse var.environment and local.common_tags from Step 3 so every generated file carries consistent metadata. The obvious next question — this is getting repetitive, how do I package it? — is exactly what modules answer.
variable "services" {
description = "Logical services to generate a config file for."
type = set(string)
default = ["api", "web", "worker"]
}
resource "random_string" "suffix" {
for_each = var.services
length = 6
special = false
upper = false
}
resource "local_file" "service_config" {
for_each = var.services
filename = "${path.module}/config/${each.key}.json"
content = jsonencode({
service = each.key
environment = var.environment
bucket = "${each.key}-${random_string.suffix[each.key].result}"
tags = local.common_tags
})
}The Interact with Terraform Modules domain wants you to author a module, call it, and pass values in and out. We move the per-service logic into a child module under ./modules/service, give it its own variable inputs and an output, then call it from the root with for_each — one module instance per service.
The two files below show the boundary clearly: the child module knows nothing about which services exist (that's the caller's job via var.name), and the root knows nothing about how a service is built (that's encapsulated in the module). Run terraform init again after adding a module — the exam tests that new module sources require a re-init to be installed. With our configuration now modular, the last big Associate topic is what Terraform has been quietly tracking the whole time: state.
# modules/service/main.tf
variable "name" {
type = string
}
variable "environment" {
type = string
}
resource "random_string" "suffix" {
length = 6
special = false
upper = false
}
resource "local_file" "config" {
filename = "${path.root}/config/${var.name}.json"
content = jsonencode({
service = var.name
environment = var.environment
bucket = "${var.name}-${random_string.suffix.result}"
})
}
output "bucket_name" {
value = "${var.name}-${random_string.suffix.result}"
}
# main.tf (root) - call the module once per service
module "service" {
source = "./modules/service"
for_each = var.services
name = each.key
environment = var.environment
}
output "service_buckets" {
value = { for k, m in module.service : k => m.bucket_name }
}The Implement and Maintain State (19%) and Use Terraform Outside the Core Workflow (9%) domains both live here. State is the JSON ledger mapping your config addresses to real objects; terraform state list enumerates it and terraform state show <addr> prints one entry. The exam expects you to know that renaming a resource in config would normally destroy-and-recreate it — unless you tell Terraform the address moved.
A moved block does exactly that declaratively: rename local_file.greeting (from Step 2) to local_file.welcome and the moved block migrates state in place, so plan shows a move, not a destroy-and-recreate. (The imperative equivalent is terraform state mv local_file.greeting local_file.welcome.) We also show a config-driven import block — the 1.5+ way to adopt a pre-existing object into state without the older terraform import CLI command. With state under control, there's one capability left to meet: running all of this remotely.
# Renaming a resource? A "moved" block migrates state in place
# instead of destroying and recreating the object. Replace the
# Step 2 "greeting" resource with this renamed "welcome" one.
moved {
from = local_file.greeting
to = local_file.welcome
}
resource "local_file" "welcome" {
filename = "${path.module}/hello.txt"
content = "Hello from ${random_pet.name.id}!\n"
}
# Config-driven import (Terraform 1.5+): adopt an object that
# already exists into state, no "terraform import" CLI command.
# terraform_data is a built-in resource - nothing to provision.
import {
to = terraform_data.tracked
id = "existing-id"
}
resource "terraform_data" "tracked" {}The final domain, Understand HCP Terraform Capabilities (5%), rounds out the exam. A single cloud block inside terraform {} swaps local execution for HCP Terraform: state lives remotely and is locked during runs, terraform plan/apply execute on HashiCorp's runners, and the run output (plus a stored plan) shows up in the web UI. This is also where the exam contrasts HCP Terraform features — remote state, run history, policy enforcement with Sentinel/OPA, and a private module registry — against the purely local workflow we used in Steps 1–6.
Replace my-org with your own organization, run terraform login once to store an API token, then terraform init to migrate state to the remote workspace. Everything you wrote in this lab now runs unchanged — only where it runs has moved. That round-trip, from a bare main.tf to a remote-backed workspace, is the whole Associate arc in one sitting.
terraform {
cloud {
organization = "my-org"
workspaces {
name = "terraform-associate-lab"
}
}
}
# Run once to authenticate, then re-init to migrate state:
# terraform login
# terraform initEverything lives on your machine, so cleanup is quick:
terraform destroy to remove the generated files and clear them from state.terraform-associate-lab workspace from the HCP Terraform UI (or run terraform destroy against it first), then remove the cloud block.cd .. && rm -rf tf-associate-lab. The local .terraform/ plugin cache, .terraform.lock.hcl, and terraform.tfstate go with it.The Associate exam is about Terraform the tool, not any one cloud, so this lab intentionally provisions no cloud infrastructure. We skip AWS / Azure / GCP resources on purpose: they require credentials, can incur cost, and would distract from the mechanics the exam actually tests — the workflow, state, configuration language, and modules.
A few Associate topics are best read about rather than run: backends other than HCP Terraform (S3, Azure Blob, GCS, Consul), provisioners like remote-exec (which HashiCorp lists as a last resort), and workspaces for managing multiple state instances. For those, the Browse and Playbook sections of this cert page have the conceptual coverage. The hands-on value here is the muscle memory of init → plan → apply, reading a state file, and refactoring safely with moved.