Terraform
Terraform is the tool infrastructure as code used as its running example: a command-line engine that reads declarative configuration written in HashiCorp Configuration Language (HCL), builds a dependency graph out of the resources you've described, and drives that graph toward reality through provider plugins — one per cloud or service, each translating HCL resource blocks into that service's own API calls. The same core workflow — init, plan, apply — targets AWS, Azure, GCP, Kubernetes, Datadog, GitHub, and thousands of other providers, which is most of why it became the closest thing the industry has to a lingua franca for provisioning. That earlier lesson covered the vendor-neutral model: declarative vs. imperative, idempotency, drift, and plan-then-apply. This page assumes that and goes straight to what you actually type — HCL syntax, the state file and how its remote locking works, workspace patterns, the module ecosystem, the day-to-day command set, and the handful of gotchas that account for most state-drift incidents in the wild.
Hand a contractor a blueprint that says "this house has three bedrooms and a blue front door" — not a list of hammer swings, just the finished picture. Terraform is that contractor: it walks around the actual house first and circles everything that doesn't match the blueprint yet, hands you that list before touching a single nail, and only picks up tools once you say go. It also keeps a logbook of every board it has ever placed — that logbook is the state file — so the next time you hand it an updated blueprint, it touches only the boards that actually need to change, not the whole house.
What Terraform is, and the ecosystem around it
☺ Like you're 10: Terraform reads your HCL files, asks a plugin called a provider to talk to the real cloud, and writes down what it did in one file called state.
HashiCorp first released Terraform in 2014. Its core is a single static binary — no server, no agent installed anywhere, no daemon watching your infrastructure between runs. Everything happens when you invoke the CLI: it parses your HCL, resolves the resources into a directed acyclic graph based on the references between them, and walks that graph — creating, updating, or destroying resources in parallel wherever the graph allows it, serialized wherever one resource depends on another's output. That "no server-side component for the open-source core" shape is the same one Helm settled on in its own domain: the tool is a stateless client, and whatever it does gets recorded, not remembered by a running process.
HashiCorp's paid products — Terraform Cloud and Terraform Enterprise, now marketed together as HCP Terraform — add a hosted control plane on top: managed remote state, a UI for reviewing plans, policy checks (via Sentinel or OPA) before an apply is allowed to run, and a private module registry. None of that is required to use Terraform. The open-source binary works standalone against a self-managed backend, and everything on this page applies whether or not you ever touch HashiCorp's hosted product.
Terraform provisions infrastructure. It deliberately stops there: it does not configure what runs inside a machine once it exists — that's configuration management, the job Ansible does — and it does not build the machine image itself, which is Packer's job, covered alongside immutable infrastructure & golden images. Terraform is one piece of a broader HashiCorp suite that also includes Vault for secrets and Consul for service networking; this page covers Terraform alone, but the boundary between it and its neighbors is worth holding onto, because a design that tries to make Terraform do configuration management or secret storage is usually fighting the tool rather than using it.
HCL: the language you actually write
☺ Like you're 10: HCL is blocks with a type, a name, and settings inside — like filling out a labeled form instead of writing a sentence.
Every .tf file is made of blocks: a keyword, zero or more labels in quotes, and a body in { } holding key = value arguments. The block types you'll write constantly are terraform (settings for Terraform itself — required version, required providers, backend config), provider (configures one provider, optionally more than once via alias for multi-region or multi-account setups), resource "TYPE" "NAME" (something Terraform creates and owns), data "TYPE" "NAME" (something Terraform only reads — an existing VPC, an AMI lookup — and never modifies), variable (an input, with an optional type, default, and validation block), locals (named expressions computed once and reused), output (a value exposed after apply, and to any config reading this one's state), and module (invoking another set of these same blocks as a unit, covered below).
References resolve without needing a template syntax for the common case — var.environment, local.name_prefix, aws_instance.web.id, module.vpc.vpc_id are just expressions. Interpolation with "${...}" is still how you embed an expression inside a larger string. A short but genuinely representative example, using a handful of the constructs you'll reach for immediately: a validated variable, a dynamic block for a variable-length nested structure, for_each to create one resource per item in a set (stable, name-based addressing — more on why that matters below), a ternary for an environment-dependent value, and a for expression building an output list.
variable "environment" {
type = string
description = "Deployment environment name"
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be one of: dev, staging, prod."
}
}
variable "allowed_ports" {
type = list(number)
default = [80, 443]
}
locals {
name_prefix = "checkout-${var.environment}"
}
resource "aws_security_group" "web" {
name = "${local.name_prefix}-web"
vpc_id = data.aws_vpc.main.id
dynamic "ingress" {
for_each = var.allowed_ports
content {
from_port = ingress.value
to_port = ingress.value
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
}
resource "aws_instance" "web" {
for_each = toset(["a", "b", "c"])
ami = data.aws_ami.app.id
instance_type = var.environment == "prod" ? "m5.large" : "t3.micro"
tags = {
Name = "${local.name_prefix}-${each.key}"
Environment = var.environment
}
}
output "instance_ids" {
description = "IDs of every web instance created"
value = [for i in aws_instance.web : i.id]
}The function library behind expressions like contains, toset, and the for expression above is large — string, collection, encoding, filesystem, date, and IP-math functions are all built in, and terraform console (see the command list below) is the fastest way to try one against real values instead of guessing. One function-shaped trap is worth flagging early: functions like timestamp() and uuid() return a new value on every single evaluation, so a resource argument built from one produces a plan diff on every run forever, even when nothing meaningful changed — the same idempotency-breaking pattern infrastructure as code warned about in the abstract, concretely reachable through two specific functions.
The core workflow: init, plan, apply
☺ Like you're 10: Init gets the tools, plan shows you the list of changes before anything happens, and apply is the only step allowed to actually touch anything.
terraform init runs first and is the one command every other command assumes already happened. It reads the required_providers block, resolves version constraints, and downloads matching provider plugins into a local .terraform/ directory; it also initializes whatever backend block you've configured, and writes (or reads, if one exists) .terraform.lock.hcl — a dependency lock file recording the exact provider versions and cryptographic hashes resolved, so a teammate or a CI runner gets byte-identical providers rather than "whatever satisfied the version constraint today." .terraform.lock.hcl belongs in version control; .terraform/ does not.
terraform plan is the read-only step: by default it refreshes its picture of real infrastructure through the provider APIs, computes the diff against your HCL, and prints it without changing anything. Plan output marks every resource with a symbol — + create, - destroy, ~ update in place, -/+ destroy and recreate (some attributes force this; others update cleanly), and <= next to a data source or attribute whose value won't be known until the real apply runs ("known after apply"). Saving that exact plan with -out and later applying the saved file — rather than re-running plan implicitly inside apply — is the difference between "what gets applied is exactly what a reviewer approved" and "what gets applied is a fresh diff computed seconds before, against whatever the target looks like right now," which can have quietly changed if someone else applied something in between.
$ terraform init # providers into .terraform/, writes/reads .terraform.lock.hcl
$ terraform init -upgrade # re-resolve provider versions within your constraints
$ terraform fmt -recursive -check # canonical formatting; -check for CI, no rewrite
$ terraform validate # syntax + internal consistency — no state, no API calls
$ terraform plan -out=tfplan # compute the diff, save the EXACT plan to a file
$ terraform show tfplan # human-readable read-back of a saved plan
$ terraform apply tfplan # apply exactly what was reviewed — no re-diff, no surprises
$ terraform apply # or: compute a fresh plan and prompt for confirmation
$ terraform plan -refresh-only # sync state to reality, propose no real infra changes
$ terraform apply -refresh-only # apply that sync
$ terraform destroy # plan and apply the removal of everything this config ownsIf you're preparing for HashiCorp's own vendor exam rather than just using the tool day to day, this workflow — init, plan, apply, state, providers, and modules — is close to the entire syllabus; see Terraform Associate for the exam-specific breakdown.
State, remote backends, and locking
☺ Like you're 10: State is the logbook, remote backends are a shared logbook everyone reads from the same place, and locking makes sure only one person writes in it at a time.
Without any backend configuration, Terraform writes state to a local terraform.tfstate file — plain JSON holding every tracked resource's address, its full set of attributes as last known, and the dependency graph between them. That file is the only thing that lets Terraform tell "a resource I own" apart from "a resource that merely happens to match my config," which is exactly why it must never be treated as disposable, and never committed to a shared repo as a local file — a second person applying against their own copy of that same file is how two people's changes silently diverge.
A remote backend moves that file to shared, versioned, access-controlled storage and adds locking on top. The classic AWS pattern pairs an S3 bucket (versioned, encrypted) with a DynamoDB table for locking; Terraform 1.10 added native S3 locking via use_lockfile, which removes the separate DynamoDB table for teams on a recent enough version — check your Terraform version before assuming it's available. Azure's azurerm backend locks via a blob lease; GCS locks natively; HCP Terraform manages both state and locking for you as a hosted service. Whichever backend, the shape is the same: a lock is acquired before plan or apply begins doing anything that touches state, held for the operation's duration, and released after.
terraform {
required_version = ">= 1.9.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.60"
}
}
backend "s3" {
bucket = "acme-terraform-state"
key = "checkout/prod/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "acme-terraform-locks" # or use_lockfile = true on Terraform 1.10+
encrypt = true
}
}A large system is rarely one Terraform config — most teams split it into layered stacks (a network stack, an app stack, a data stack) each with its own state, and wire them together with the terraform_remote_state data source, which reads another stack's output values straight out of its state file. Day-to-day state inspection and surgery uses a small, distinct command family from plan/apply:
$ terraform state list # every resource address Terraform tracks
$ terraform state show 'aws_instance.web["a"]' # full attributes of one tracked resource
$ terraform state mv aws_instance.web aws_instance.app_server # rename in state — no destroy, no create
$ terraform state rm aws_instance.web # stop tracking it; the REAL resource is untouched
$ terraform import aws_instance.web i-0123456789abcdef0 # bring an existing, unmanaged resource under control
$ terraform force-unlock LOCK_ID # only after you've confirmed nothing else is genuinely applyingTerraform 1.5 added a declarative alternative to import — an import block in HCL, plannable and reviewable like any other change, rather than a one-off imperative command run against nobody's PR. For anything beyond a one-time fix, prefer it.
Marking an output as sensitive = true only redacts it from CLI and UI display — the raw value is still written to the state file in plaintext, attribute by attribute, including anything passed as a plain resource argument (a database password set directly in HCL, a generated API key). State file access control is therefore a real secret boundary, not a formality: encrypt the backend, restrict who can read the bucket or the HCP Terraform workspace, and keep actual secrets out of HCL and state entirely by injecting them at apply time from a real secret store — see HashiCorp Vault and secrets & credential management.
Workspaces: what they're for, and what they aren't
☺ Like you're 10: A workspace is a separate notebook page for the same blueprint — good for near-identical copies, risky as your only wall between dev and prod.
terraform workspace new preview-pr-482, terraform workspace select prod, terraform workspace list, terraform workspace show — a workspace is a named, isolated slice of state under the same backend and the same configuration, addressable inside HCL as terraform.workspace. Locally, each gets its own file under terraform.tfstate.d/<name>/; on a remote backend, the state key gets a workspace-specific suffix. They're genuinely useful for many structurally identical, low-stakes instances of one config — a fresh ephemeral environment per feature-branch preview, a parallel copy per test region.
What they are not is environment isolation. Every workspace shares the same backend credentials, the same provider configuration, and — unless you're careful with -var-file — the same variable defaults; nothing stops terraform workspace select prod followed by an unreviewed local apply from landing on production with whatever variables happened to be in your shell. HashiCorp's own guidance, and most mature teams' practice, is to reserve workspaces for same-shape, lower-stakes multiplicity, and to give anything as consequential as prod its own root configuration or its own explicit backend key and variable file — with its own review gate — rather than a workspace name as the only thing standing between a laptop and a production apply.
The module ecosystem
☺ Like you're 10: A module is a config you can call by name instead of retyping — the same trick as a function, but for infrastructure.
A module is any directory of .tf files invoked through a module block; the directory you actually run Terraform in is the root module, and anything it calls is a child module, which can itself call further children. The source argument accepts a local relative path (./modules/vpc), a short form resolving against the public Terraform Registry (terraform-aws-modules/vpc/aws), a Git URL with an optional subdirectory and ref (git::https://github.com/org/repo.git//modules/foo?ref=v1.2.0), or a generic HTTP/S3 archive. Version constraints on registry modules use the same operators as providers, most commonly the pessimistic ~> — ~> 5.13 means "at least 5.13, less than 6.0."
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.13"
name = "checkout-${var.environment}"
cidr = "10.20.0.0/16"
azs = ["us-east-1a", "us-east-1b"]
private_subnets = ["10.20.1.0/24", "10.20.2.0/24"]
public_subnets = ["10.20.101.0/24", "10.20.102.0/24"]
}
module "app" {
source = "./modules/service"
count = var.environment == "prod" ? 3 : 1
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnets
}Modules compose the way a chart's dependencies do in Helm: outputs from one become inputs to the next (module.vpc.vpc_id feeding module.app above), count or for_each on a module block creates multiple instances of the same child, and a platform team publishes one paved-road module — say, "the standard internal service" — that every application team consumes with a short, mostly-values-only block, picking up security and networking fixes by bumping a version number instead of copy-pasting HCL. The public Terraform Registry hosts both providers and modules; teams that need to publish internal modules privately typically use HCP Terraform's private registry or simply a Git source with tags as versions. Practice writing one of your own in Drill — Write a Reusable IaC Module, and see it land in a real stack in Capstone Part 2 — Infrastructure as Code.
Day-to-day commands
☺ Like you're 10: A handful of commands cover almost everything: get the plugins, format it, check it, see the diff, ship the diff.
$ terraform providers # the resolved provider tree for this config
$ terraform providers lock -platform=linux_amd64 -platform=darwin_arm64 # cross-platform lock hashes
$ terraform console # a REPL for trying expressions against real state
$ terraform output # every root-module output value
$ terraform output -json instance_ids # one output, machine-readable
$ terraform graph | dot -Tsvg > graph.svg # the dependency graph, rendered with Graphviz
$ terraform apply -replace='aws_instance.web["a"]' # force one resource to be destroyed and recreated
$ terraform plan -target=aws_security_group.web # scope a plan to one resource — emergency use onlyOn a disposable AWS sandbox account (or with the null_resource and local_file providers, which touch nothing external): write the config above, run terraform plan -out=tfplan, read every line of the plan before you do anything else, then terraform apply tfplan. Re-run terraform plan immediately after — zero changes is idempotency working. Now hand-edit one tag through whatever console the provider gives you, run plan again, and watch Terraform propose reverting your manual change. That's drift, caught the way it's supposed to be caught: before an apply, not after an incident.
Gotchas and state-drift failure modes
☺ Like you're 10: Most Terraform surprises come from the graph caring about names, not intentions — rename something and it can think you deleted it.
Resource renames and count → for_each: the surprise replace
Terraform's graph is addressed by name, not by intent. Rename a resource block from aws_instance.web to aws_instance.app_server, or migrate a resource from count to for_each, and a plain plan reads as "old address gone, new address appeared" — a destroy paired with a create, even though the underlying cloud resource should simply be relabeled. Two fixes exist, and neither is optional for anything stateful: a moved block (Terraform 1.1+, itself reviewable in a plan) or terraform state mv run by hand before the plan. The count-to-for_each case has a second layer: count addresses resources by numeric index, so removing an item from the middle of a list shifts every following index and can cascade into replacing resources that never actually changed; for_each over a set or map addresses by a stable key instead, which is why it's the safer default for anything you expect to grow, shrink, or reorder.
moved {
from = aws_instance.web
to = aws_instance.app_server
}Provider version drift and the lock file
A loose or missing version constraint plus terraform init -upgrade can silently pull a new major provider version with breaking schema changes between two people's machines, or between a laptop and CI. .terraform.lock.hcl exists specifically to prevent that — committed, it pins exact versions and hashes so everyone gets the same providers — but it's platform-specific by default; a lock file generated on a Mac won't necessarily include the Linux hashes your CI runner needs, which is what terraform providers lock -platform=... (shown above) is for.
Drift, refresh-only plans, and ignore_changes
Real infrastructure changes outside Terraform constantly — a manual console fix during an incident, an autoscaler resizing a fleet, another automation touching a tag. The next ordinary plan reads that as a diff to revert, which is correct behavior but not always the desired outcome. terraform plan -refresh-only (and apply -refresh-only) update state to match reality without proposing any real infrastructure change, for when the manual change should be kept. For fields you genuinely want a different system to own long-term — an autoscaling group's desired_capacity managed by a scaling policy, for instance — a lifecycle { ignore_changes = [...] } block tells Terraform to stop comparing that specific attribute at all. Reach for it narrowly; a resource with a wide ignore_changes list is a resource Terraform is only pretending to manage.
Stuck locks and the -target temptation
A CI job killed mid-apply leaves its lock held; every subsequent run fails immediately with an error naming the lock ID and who holds it. terraform force-unlock LOCK_ID clears it — but only after you've actually confirmed nothing else is mid-apply, because force-unlocking a lock that's genuinely still in use is how two applies end up racing against the same state, corrupting it in exactly the way locking exists to prevent. And -target, on either plan or apply, scopes an operation to one resource by skipping evaluation of the full dependency graph around it — a legitimate emergency escape hatch, and a bad habit for routine work, because it can leave state inconsistent with what a full plan would have computed.
Terraform vs. its neighbors
☺ Like you're 10: Other tools draw the same "here's what I want" picture in a different pen — the picture's rules don't change, but the trade-offs do.
| Option | Model | Best when | Costs you |
|---|---|---|---|
| Terraform | Multi-cloud DSL (HCL), self-managed state | You provision across more than one provider and want one workflow for all of them | You own the state file and its backend; HCL is a DSL to learn on top of the cloud itself |
| OpenTofu | Community fork of Terraform under the Linux Foundation, same HCL and state format | You want the Terraform model without HashiCorp's BUSL license terms | A younger ecosystem, and near-term risk of the two projects' behavior slowly diverging |
| Pulumi | Same declarative model, expressed in a general-purpose language (TypeScript, Python, Go) | Your team wants real loops, unit tests, and IDE tooling instead of a bespoke DSL | A smaller module ecosystem than Terraform's registry |
| CloudFormation / ARM / Deployment Manager | Native to one cloud, state managed by the vendor | You're single-cloud and want one less system (a state backend) to operate yourself | No portability beyond that vendor, and a weaker module ecosystem outside it |
| Ansible (paired, not competing) | Imperative-leaning, largely agentless configuration management | Configuring what runs inside a machine Terraform already provisioned | A different job entirely — see configuration management for the boundary |
If you're preparing specifically for AWS's own DOP-C02 exam rather than Terraform's, the AWS-native side of this same territory — CloudFormation, the CDK, StackSets, Systems Manager — gets its own dedicated coverage in Configuration Management & IaC. Everything on this page instead — HCL, plan/apply, state, and modules — maps closely onto HashiCorp's own Terraform Associate certification, which is deliberately narrow (one tool, not the wider IaC model) and knowledge-based rather than hands-on, making it one of the faster wins on this course's certifications page if you're building out a résumé alongside Vault Associate for the secrets side of the same toolchain.
Recon: Plan complete. One resource renamed in HCL, zero changes on the real infrastructure — but the graph doesn't know that. It sees aws_instance.web gone and aws_instance.app_server new. Destroy, then create.
Foxy: It's the same server! Why would you delete a running instance just because I renamed a label?
Recon: Because I track addresses, not intent, Foxy. Add a moved block, or run terraform state mv, and I'll know it's the same resource under a new name.
Benny: Learned that one the hard way on a count-to-for_each migration. Ten servers, ten "no changes needed" — because the keys stayed stable across the rename and the indexes didn't have to shuffle.
Gizmo: Or you could just apply -target the one resource and skip the graph entirely. Faster. 🤑
Timmy: And skip everything downstream of it that also needed to move. Read the full plan, every time, Gizmo — or don't apply at all.
1. What does terraform plan -out=tfplan followed by terraform apply tfplan guarantee that a bare terraform apply in CI doesn't? 2. Where does Terraform state live by default, and name two remote backends that add locking on top. 3. You rename aws_instance.web to aws_instance.app_server with no real infrastructure change intended. What does a plain plan show, and what are the two ways to prevent it? 4. Marking an output sensitive = true hides it from the CLI. Does it keep the value out of the state file? 5. Why aren't Terraform workspaces a substitute for separate prod and staging configurations?
Check your answers
- It guarantees the plan that gets applied is byte-for-byte the plan a reviewer already approved. A bare
applycomputes a fresh diff at apply time, which can differ from what was reviewed if the target infrastructure or state changed in between — exactly the race that state locking also exists to prevent. - By default, a local
terraform.tfstatefile (plain JSON). Remote backends that add locking include S3 (with a DynamoDB table, or native locking viause_lockfileon Terraform 1.10+), Azure'sazurermbackend (blob lease), GCS (native), and HCP Terraform (managed). - A plain plan shows a destroy paired with a create — Terraform addresses resources by name, not intent, so a rename reads as "old address gone, new address appeared." Prevent it with a
movedblock or by runningterraform state mvbefore planning. - No.
sensitive = trueonly redacts the value from CLI and UI display — the raw value is still written to the state file in plaintext. State file access control (encryption, restricted read access) is the real protection, not the sensitive flag alone. - Every workspace shares the same backend credentials, provider configuration, and often the same variable defaults, so selecting a different workspace doesn't isolate you from an unreviewed apply landing on the wrong environment. Consequential environments like prod are safer with their own root configuration or an explicit backend key and variable file, with their own review gate, rather than a workspace name as the only boundary.