| name | terraform |
| description | Use for authoring Terraform / HCL infrastructure-as-code: provider declarations (AWS, GCP, Azure), resources (VPC, subnet, security group, EC2, S3 / GCS, IAM, RDS), variables, locals, outputs, modules (calling and authoring), data sources, count vs for_each, lifecycle meta-arguments (prevent_destroy, create_before_destroy, ignore_changes), backends and remote state, and workspaces. |
| metadata | {"dependencies":"python-hcl2 # test-side parser for verifying emitted HCL artifacts"} |
Terraform / HCL Field Guide
A Terraform configuration is a directory of .tf files in HCL. Tasks here
emit HCL as text artifacts; downstream parsers / validators read those
artifacts. You are not expected to run terraform apply. Aim for
configurations that parse cleanly with python-hcl2, name resources and
variables in a way the validator can locate, and pin only the meta-args
each task asks for.
Three questions before authoring:
- What providers and resource types does the task name? (Match exactly.)
- What inputs are variables vs hard-coded? (Validators inspect both.)
- Which outputs / module boundaries does the test reach for?
§1. Block types
Terraform's top-level blocks. Each .tf file is a flat list of these.
| Block | Purpose | When to declare |
|---|
terraform | Engine + provider version pins, backend config | Once per config root |
provider | Per-cloud credentials and region | Once per provider in use |
resource | A managed cloud object (creates / updates / destroys) | Each piece of infra |
data | Read-only lookup of an existing object | When referencing pre-existing infra |
variable | Typed input parameter | Anything env- or caller-specific |
local | Named expression, computed once | Reused expressions, dependency hints |
output | Value exposed to caller / remote-state consumer | Module / root return values |
module | Call to a child module | Composing reusable building blocks |
§2. Providers
Minimal provider declarations. Pin major versions with ~>.
terraform {
required_version = "~> 1.6"
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
google = { source = "hashicorp/google", version = "~> 5.0" }
azurerm = { source = "hashicorp/azurerm", version = "~> 3.0" }
}
}
provider "aws" { region = var.aws_region }
provider "google" { project = var.gcp_project, region = var.gcp_region }
provider "azurerm" { features {} }
Multiple regions: alias a second provider block (alias = "us_west") and
pass provider = aws.us_west on resources that should land there.
§3. Resource gallery
Six representative resources. Argument names are exact — validators match
on them.
VPC + subnet + security group (AWS)
resource "aws_vpc" "this" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
tags = { Name = "${var.name}-vpc" }
}
resource "aws_subnet" "private" {
for_each = toset(var.availability_zones)
vpc_id = aws_vpc.this.id
availability_zone = each.key
cidr_block = cidrsubnet(aws_vpc.this.cidr_block, 8, index(var.availability_zones, each.key))
tags = { Name = "${var.name}-private-${each.key}" }
}
resource "aws_security_group" "web" {
name = "${var.name}-web"
vpc_id = aws_vpc.this.id
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
EC2 / Compute Instance
resource "aws_instance" "web" {
ami = data.aws_ami.al2023.id
instance_type = var.instance_type
subnet_id = aws_subnet.private["us-east-1a"].id
vpc_security_group_ids = [aws_security_group.web.id]
tags = { Name = "${var.name}-web" }
}
resource "google_compute_instance" "web" {
name = "${var.name}-web"
machine_type = "e2-small"
zone = "${var.gcp_region}-a"
boot_disk { initialize_params { image = "debian-cloud/debian-12" } }
network_interface { network = "default", access_config {} }
}
Object storage (S3, GCS)
resource "aws_s3_bucket" "logs" { bucket = "${var.name}-logs" }
resource "aws_s3_bucket_versioning" "logs" {
bucket = aws_s3_bucket.logs.id
versioning_configuration { status = "Enabled" }
}
resource "google_storage_bucket" "logs" {
name = "${var.name}-logs"
location = "US"
uniform_bucket_level_access = true
}
IAM role + policy attachment (AWS)
resource "aws_iam_role" "ec2" {
name = "${var.name}-ec2"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow", Action = "sts:AssumeRole",
Principal = { Service = "ec2.amazonaws.com" }
}]
})
}
resource "aws_iam_role_policy_attachment" "ec2_ssm" {
role = aws_iam_role.ec2.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
Managed database (RDS / Cloud SQL)
resource "aws_db_instance" "app" {
identifier = "${var.name}-pg"
engine = "postgres"
engine_version = "15"
instance_class = "db.t3.micro"
allocated_storage = 20
username = var.db_user
password = var.db_password
skip_final_snapshot = true
vpc_security_group_ids = [aws_security_group.web.id]
}
§4. Variables, locals, outputs
variable "name" {
description = "Project name prefix for tagged resources."
type = string
validation {
condition = can(regex("^[a-z][a-z0-9-]{1,30}$", var.name))
error_message = "name must be lowercase, start with a letter, max 31 chars."
}
}
variable "availability_zones" {
description = "AZs to create one private subnet per."
type = list(string)
default = ["us-east-1a", "us-east-1b"]
}
variable "db_password" {
type = string
sensitive = true
}
locals {
common_tags = { Project = var.name, ManagedBy = "terraform" }
# try() picks the first non-error reference — encodes a deletion-order hint
vpc_id = try(aws_vpc_ipv4_cidr_block_association.extra[0].vpc_id, aws_vpc.this.id)
}
output "vpc_id" { value = aws_vpc.this.id }
output "db_endpoint" { value = aws_db_instance.app.endpoint, sensitive = true }
References: var.name, local.common_tags, module.network.vpc_id,
data.aws_ami.al2023.id, each.key / each.value (for_each),
count.index (count).
§5. Modules
Calling a module:
module "network" {
source = "./modules/network"
name = var.name
vpc_cidr = "10.0.0.0/16"
}
module "vpc_registry" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
name = var.name
cidr = "10.0.0.0/16"
}
Authoring a module (directory modules/network/):
modules/network/
├── main.tf # resources
├── variables.tf # inputs (description + type required)
├── outputs.tf # exported values
└── versions.tf # required_version + required_providers
A module is just a directory of .tf files referenced by source. Inputs
are its variable blocks; return values are its output blocks. Keep one
responsibility per module (network, compute, data) — composition lives in
the caller.
§6. Iteration: count vs for_each
| Use | When | Address shape |
|---|
count = cond ? 1 : 0 | Boolean toggle for an optional resource | aws_x.this[0] |
count = N | Fixed number of identical instances | aws_x.this[i] |
for_each = toset(list) | Stable per-key identity, set of strings | aws_x.this["key"] |
for_each = map | Per-key config, named access | aws_x.this["key"] |
for_each over a list whose middle element is removed keeps the survivors
in place. count on the same change reindexes everything past the removed
slot and triggers destroy/recreate. Default to for_each for collections
with identity.
resource "aws_iam_user" "team" {
for_each = toset(var.team_members)
name = each.key
}
resource "aws_eip" "nat" {
count = var.create_nat ? 1 : 0
domain = "vpc"
}
§7. Lifecycle meta-arguments
Inside any resource block:
lifecycle {
prevent_destroy = true # refuse `terraform destroy`
create_before_destroy = true # spin up replacement first
ignore_changes = [tags["LastSeen"]] # drift on this attribute is fine
}
When each matters:
prevent_destroy — guard production data stores (RDS, S3 buckets with
retained data). Removes the resource block still requires manual
override; this is intentional.
create_before_destroy — for resources that hold an in-use identifier
(NAT gateway, launch template, ASG) where the replacement must exist
before the old one goes away. Pair with name_prefix rather than name
so two can coexist briefly.
ignore_changes — fields mutated outside Terraform (autoscaling
desired_capacity, externally rotated tags). Scope tightly; broad
ignore_changes = all hides real drift.
§8. State and backends
State is the source of truth Terraform reconciles against. By default it
lives in terraform.tfstate next to the config; for any shared use,
configure a remote backend.
terraform {
backend "s3" {
bucket = "tfstate-prod"
key = "network/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "tfstate-locks" # state locking
encrypt = true
}
}
GCS equivalent: backend "gcs" { bucket = "...", prefix = "network" }.
Azure: backend "azurerm" with storage_account_name and container_name.
Cross-stack reads via terraform_remote_state:
data "terraform_remote_state" "network" {
backend = "s3"
config = { bucket = "tfstate-prod", key = "network/terraform.tfstate", region = "us-east-1" }
}
resource "aws_instance" "app" {
subnet_id = data.terraform_remote_state.network.outputs.private_subnet_ids[0]
# ...
}
State import (adopting an existing resource without recreating it):
# 1. Write the resource block matching the live object.
resource "aws_s3_bucket" "legacy" { bucket = "company-legacy-logs" }
# 2. Import (CLI) — or use the import {} block in 1.5+:
import {
to = aws_s3_bucket.legacy
id = "company-legacy-logs"
}
# 3. terraform plan — must show zero changes before any apply.
Workspaces partition state within a single backend
(terraform workspace new staging, then terraform.workspace resolves to
"staging"). Use them for short-lived parallel copies of the same config;
for environment isolation across teams, prefer separate backend keys —
workspaces share the backend's blast radius.
Test-time HCL inspection
Validators parse the emitted HCL with python-hcl2:
import hcl2
with open("main.tf") as f:
cfg = hcl2.load(f)
resources = {}
for block in cfg.get("resource", []):
for rtype, named in block.items():
for name, body in named.items():
resources[(rtype, name)] = body
assert ("aws_vpc", "this") in resources
assert resources[("aws_vpc", "this")]["cidr_block"] == "10.0.0.0/16"
Variables surface as cfg["variable"], outputs as cfg["output"],
modules as cfg["module"], providers as cfg["provider"]. References
like var.name or aws_vpc.this.id come back as the literal strings
"${var.name}" / "${aws_vpc.this.id}" — assert on substrings, not
on resolved values.
Pitfalls
- Hand-editing state files.
terraform state mv / import block / rm only.
count on a list whose order can shift — quietly recreates resources.
- A
resource declared twice with the same address — config does not
parse; choose distinct names or use for_each.
- Forgetting
required_providers — Terraform may pick a default that
does not match the resource types in use.
ignore_changes = all masking real drift; scope to the specific
attribute that actually mutates externally.
- Storing secrets in
variable defaults or in committed .tfvars. Use
sensitive = true and inject at apply time.
- Remote backend without locking (S3 without DynamoDB, GCS without object
versioning) — concurrent applies corrupt state.