Assumes you already know what Infrastructure as Code is for and can define idempotency, drift, and the provisioning-vs-configuration-management split — if any of those are new, read Automation, CI/CD & GitOps — Part 2 first. This series picks up from there and goes to full production depth on one tool.
Table of Contents#
- Why Terraform Gets Its Own Deep-Dive Series
- The Terraform Ecosystem in 2026 — HashiCorp, IBM, and the OpenTofu Fork
- Installing and Version-Pinning Terraform
- Version Constraint Operators, and Why
~>Is the Default - HCL — Blocks, Arguments, and Expressions
- Providers and the Plugin Model
- Resources — the Core Building Block
- Variables, Locals, and Outputs
- Complex Types and Optional Object Attributes
- Marking Sensitive Values
- The Plan/Apply Workflow, Step by Step
- Saving and Applying an Exact Plan File
- The Dependency Graph — How Terraform Decides Ordering
- count, for_each, and Dynamic Blocks
- The
lifecycleMeta-Argument - The Terraform Registry — Discovering Providers and Modules
- The Everyday Toolkit: fmt, validate, console
- Documenting Configuration with terraform-docs
- Worked Scenario: Provisioning checkout-service's Network Foundation
- Worked Scenario: the PR a Reviewer Should Have Rejected
- Worked Scenario: count vs. for_each, and Getting Bitten by It
- Part 1 CLI Cheat Sheet
- Common Mistakes and Interview Traps
- Worked Practice Problems
- Summary and What's Next
Why Terraform Gets Its Own Deep-Dive Series#
The IaC chapter you may already have read introduces the idea; this series teaches you to run Terraform
in production, at team scale, for years, without your state file becoming the thing everyone is afraid to
touch. Knowing that Terraform is declarative and idempotent gets a newcomer through a whiteboard question.
It does not tell you how to structure a state file so two teams stop colliding on terraform apply, how to
refactor a module without destroying and recreating a production database, how to catch a
aws_db_instance.this must be replaced line in a plan before it reaches apply, or how to keep a policy
engine from becoming the thing that blocks every deploy for the wrong reason. Those are the actual, recurring
problems a platform team hired to "own Terraform" gets paged for, and they are what this series covers.
Across all nine parts, the running example is one platform-engineering team's infrastructure for three
services that will already be familiar if you've worked through this site's Kubernetes Deep Dive or AWS
Cloud Architecture series: checkout-service, catalog-service, and inventory-service, plus the shared
networking, database, and Kubernetes-cluster infrastructure they all sit on. If you haven't read those
series, nothing here depends on it — the throughline is introduced fresh, in AWS-flavored Terraform, with
enough context to follow standalone. Chapter 9 extends the same team's setup into a second cloud to cover
genuinely multi-cloud patterns.
Note
Every code example in this series targets the AWS provider by default, because it's the most common
real-world pairing and keeps the throughline concrete. The underlying Terraform concepts — state, modules,
workspaces, the plan/apply cycle — are 100% provider-agnostic; swap aws_* resource types for azurerm_*
or google_* and everything else holds.
This chapter builds the foundation the rest of the series assumes fluency in: the current state of the
Terraform ecosystem (which changed materially in the last two years and directly affects tooling choices you
make later), the HCL language itself, and exactly what happens, internally, between typing terraform apply
and infrastructure existing.
The Terraform Ecosystem in 2026 — HashiCorp, IBM, and the OpenTofu Fork#
Terraform is no longer a single, uncontested open-source tool — it's a fork situation, and which side of the fork you're on is now a real decision every new project should make deliberately, not by default. In August 2023, HashiCorp relicensed Terraform from the permissive Mozilla Public License (MPL) 2.0 to the Business Source License (BSL) 1.1, a source-available license the Open Source Initiative doesn't recognize as open source. A group of vendors and contributors responded by forking the last MPL-licensed release into OpenTofu, now governed by the Linux Foundation and holding CNCF sandbox status. In February 2025, IBM completed a $6.4B acquisition of HashiCorp, folding Terraform and Vault into IBM's automation portfolio.
The practical fork has genuinely diverged, not just renamed the binary. OpenTofu has shipped state
encryption, early variable evaluation (including in backend configuration blocks — a real pain point this
chapter's later sections work around for Terraform), and a provider for_each construct that lets you
dynamically generate provider configurations, none of which exist in Terraform's own CLI as of this writing.
On the other side, Terraform still has the larger install base, HashiCorp's official provider development,
and HCP Terraform's managed run pipeline. Every configuration snippet in this series is written in syntax
that runs unmodified on both — the two haven't diverged at the HCL syntax level, only in CLI-level features —
so nothing here locks you to one or the other.
What actually changed for practitioners in 2026, and why it matters when you pick a state backend and run-pipeline strategy in Part 2 and Part 8: HCP Terraform's legacy unlimited-seat Free plan reached end of life on March 31, 2026. The replacement free tier caps at 500 managed resources, and paid tiers now bill per resource under management rather than per seat (Essentials $0.10, Standard $0.47, Premium $0.99 per resource/month, published February 2026). For a platform team managing hundreds of resources across several environments, this is a real budget-line decision, not a rounding error — it's a major reason more teams are now evaluating a self-hosted backend (Part 2) and a self-hosted run pipeline like Atlantis (Part 8) instead of defaulting to HCP Terraform, and part of why OpenTofu's download numbers have grown sharply since.
| Question | Terraform (HashiCorp/IBM) | OpenTofu (Linux Foundation) |
|---|---|---|
| License | BSL 1.1 — free to use, restricted for competing commercial products | MPL 2.0 — genuinely open source |
| CLI compatibility | Reference implementation | Drop-in compatible; same HCL, same provider protocol |
| Managed run pipeline | HCP Terraform (formerly Terraform Cloud), now RUM-priced | No first-party equivalent; pairs with Spacelift, env0, Scalr, or self-hosted Atlantis |
| Policy engine | Sentinel (HCP Terraform/Enterprise only, not portable) | OPA/Conftest (portable, works with either tool) |
| Newest features | Import blocks inside modules, deferred actions (experimental) | State encryption, provider for_each, early variable evaluation |
| Best fit | Teams already invested in HCP Terraform/Sentinel, or needing HashiCorp support contracts | Teams prioritizing license certainty, or building a SaaS product on top of the tool itself |
Tip
Best practice: default new projects to whichever tool your organization has already standardized on — switching later is low-friction precisely because the HCL and provider ecosystem are shared. If you're starting completely greenfield with no existing constraint, OpenTofu removes a genuine legal-review line item for companies building commercial infrastructure tooling on top of Terraform itself; if you need HashiCorp's commercial support SLA or already run Sentinel policies, staying on Terraform is the lower- friction choice. Don't decide this by habit — it's a real, current tradeoff.
From the Trenches — the free-tier migration nobody budgeted for#
A platform team of four ran their entire multi-environment setup — checkout-service, catalog-service,
and inventory-service across dev, staging, and production — on HCP Terraform's legacy Free plan for two
years, comfortably under any resource ceiling anyone had ever checked. When the March 2026 EOL notice landed,
the team's first reaction was "we're a small team, this won't affect us." The immediate cause: nobody had
ever actually counted managed resources, because the legacy plan had no resource-based billing to make that
number visible day to day. The underlying condition: three environments times roughly 40 resources per
service (VPC, subnets, security groups, an RDS instance, an EKS node group, IAM roles, S3 buckets) put them
at just over 500 — past the new free tier's cap the moment they migrated. The fix wasn't dramatic (a Standard-
tier subscription at $0.47/resource/month), but the real lesson was that resource count is a metric worth
tracking on a dashboard well before a pricing change forces you to look, the same way you'd track pod
count or open file descriptors — treat it as a capacity signal, not a bill you only read once a month.
Installing and Version-Pinning Terraform#
Pin your Terraform version explicitly in every configuration — an unpinned version is one of the most common sources of "works on my machine" drift on a Terraform team. Terraform's own minor-version releases occasionally change plan output formatting, deprecate arguments, or alter default behavior (the S3 backend's locking mechanism, covered in Part 2, is a recent example). Two engineers running different CLI versions against the same state file can get different plans for identical code.
terraform {
required_version = ">= 1.10.0, < 2.0.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.60"
}
}
}For local version management, tenv (the actively maintained successor to the now-archived tfenv) handles
both Terraform and OpenTofu version switching per-project, reading a .terraform-version or
.opentofu-version file:
tenv tf install 1.15.8
tenv tf use 1.15.8
terraform versionNote
required_version only validates the running CLI version against your constraint at init/plan time
— it does not install or switch versions for you. Pair it with tenv (or your CI runner's own version-
pinning step) so the constraint is actually enforced, not just documented.
Version Constraint Operators, and Why ~> Is the Default#
required_version and required_providers both accept the same small set of comparison operators, and
picking the wrong one is a frequent source of a surprise breaking change landing silently in CI.
| Operator | Meaning | Example | When to use |
|---|---|---|---|
= | Exact version only | = 5.60.0 | Rare — pins out every future fix, including security patches |
!= | Excludes one version | != 5.61.0 | A known-bad release you're deliberately skipping |
>=, <=, >, < | Open-ended bound | >= 1.10.0 | Combine with an upper bound; rarely used alone |
~> | "Pessimistic" constraint — allows the rightmost version segment to increment only | ~> 5.60 | The default for provider versions — patch/minor upgrades flow in, majors don't |
~> 5.60 allows 5.60.1, 5.61.0, and anything up to (but not including) 6.0.0 — it locks the major
version (where a provider's breaking changes live, per its own semantic-versioning promise) while still
letting bug fixes and new resource types in without a manual bump. ~> 5.60.0 (three segments) is stricter
still, locking the minor version too and only allowing patch releases through. Pick the two-segment form
for most providers; drop to the three-segment form only for a provider whose changelog has burned you with
minor-version behavior changes before.
Tip
Best practice: commit the .terraform.lock.hcl file generated by terraform init to version control.
It records the exact provider versions and checksums actually resolved, so every teammate and every CI run
gets byte-identical provider binaries even though required_providers only expresses a range. Treat an
unreviewed lock-file diff in a PR the same way you'd treat an unreviewed package-lock.json/go.sum diff
— a provider version bump deserves its own look at the changelog, not a rubber-stamp merge.
HCL — Blocks, Arguments, and Expressions#
HashiCorp Configuration Language is a declarative, block-structured language purpose-built for describing
infrastructure — every .tf file is a series of top-level blocks, each with a type, zero or more labels, and
a body of arguments and nested blocks.
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
tags = {
Name = "checkout-web"
Team = "platform"
}
}resource is the block type, "aws_instance" and "web" are labels (resource type, then local name),
and everything inside the braces is the body — ami and instance_type are arguments, tags is a nested
map value. Terraform recognizes a fixed set of top-level block types, each doing a distinct job:
Every argument's value is an expression, not just a literal — HCL supports string interpolation
("${var.env}-checkout", though bare references like var.env no longer need the ${} wrapper outside a
larger string), arithmetic, conditionals (condition ? true_val : false_val), and a substantial built-in
function library (length(), lookup(), cidrsubnet(), jsonencode(), and dozens more) for transforming
values without reaching for a provisioner or an external script.
locals {
environment = terraform.workspace
is_prod = local.environment == "prod"
instance_type = local.is_prod ? "m6i.large" : "t3.micro"
}Tip
Best practice: run terraform fmt (canonical formatting) and terraform validate (syntax and internal
consistency, no provider credentials needed) as a pre-commit hook, not just in CI. Catching a malformed
block or a typo'd reference before a PR is opened saves an entire round trip through your pipeline.
Providers and the Plugin Model#
A provider is a plugin that translates HCL resource blocks into actual API calls against a specific platform — AWS, Azure, GCP, Kubernetes, Datadog, GitHub, and hundreds of others each ship their own provider, all speaking a common plugin protocol to the Terraform core binary. This split is deliberate and important: the Terraform core binary itself knows nothing about AWS or any other platform — it only knows how to parse HCL, build a dependency graph, manage state, and orchestrate CRUD calls that a provider plugin actually implements.
This chapter's caption: the provider plugin — not the Terraform core binary — is what actually knows how
to talk to AWS; swapping aws for azurerm changes nothing about how core orchestrates the graph.
Provider configuration lives in its own block, separate from required_providers (which only declares the
version constraint and source address):
provider "aws" {
region = "us-east-1"
default_tags {
tags = {
ManagedBy = "terraform"
Team = "platform"
}
}
}default_tags is worth calling out on its own: every resource type supporting AWS tags inherits these
automatically, without repeating the block in every resource — a small feature that becomes a real cost-
attribution and governance win once you have hundreds of resources (Part 9 builds directly on this for
chargeback reporting).
Resources — the Core Building Block#
A resource block is a declaration of desired state for one real, tracked infrastructure object — "this
S3 bucket should exist, with this configuration," not an imperative "create a bucket" instruction. Every
resource has a type (aws_s3_bucket, defined by the provider) and a local name (this, logs,
whatever you choose) that's only meaningful within your configuration — it becomes part of the resource's
address (aws_s3_bucket.logs), the key Terraform uses to track it in state and in the dependency graph.
resource "aws_s3_bucket" "logs" {
bucket = "checkout-service-access-logs"
}
resource "aws_s3_bucket_versioning" "logs" {
bucket = aws_s3_bucket.logs.id
versioning_configuration {
status = "Enabled"
}
}The second block references aws_s3_bucket.logs.id — a direct attribute reference, not a hardcoded string.
This is how Terraform builds its dependency graph: it parses every expression in your configuration, finds
references to other resources, and infers "the versioning config depends on the bucket existing first"
entirely from that reference, with no explicit ordering hint from you. This inferred-dependency model is the
single most important mental shift for anyone coming from imperative scripting, where you'd write the API
calls in execution order yourself.
Important
Resource addresses are what state tracks, not resource names inside the target platform. Renaming the
local name logs to access_logs in your .tf file — with zero change to the actual bucket argument —
still tells Terraform "the old address no longer exists, a new one does," and by default that means
destroy the old resource, create a new one, even though nothing about the real infrastructure needed to
change. Part 6 covers moved blocks, the fix for exactly this trap.
Variables, Locals, and Outputs#
Three distinct mechanisms handle values in Terraform, and conflating them is a common beginner mistake:
variable blocks are external inputs, locals are internal computed values, output blocks are values
exposed to whatever calls this configuration (a human, a CI pipeline, or a parent module).
variable "environment" {
description = "Deployment environment name"
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be one of: dev, staging, prod."
}
}
locals {
name_prefix = "${var.environment}-checkout"
}
output "bucket_arn" {
description = "ARN of the access-logs bucket, consumed by the observability stack"
value = aws_s3_bucket.logs.arn
}The validation block on a variable is worth using deliberately, not just for type-checking — it turns a
bad input into a clear, immediate terraform plan-time error message instead of a confusing failure three
resources deep once the provider rejects an invalid value.
| Mechanism | Set by | Scope | Typical use |
|---|---|---|---|
variable | The caller (CLI flag, .tfvars file, environment variable, or parent module) | This module only, unless passed through | Anything that legitimately differs per environment or per caller |
locals | This module's own expressions | This module only | Computed values derived from variables/resources, used more than once |
output | This module's resource attributes | Exposed to caller | Values another module, a CI step, or a human needs after apply |
Variables can be set from several sources, with a defined precedence order — command-line -var flags win,
then *.auto.tfvars files, then TF_VAR_* environment variables, then the variable's own default.
Getting this order backward (assuming a .tfvars file always wins) is a frequent source of "why did it apply
with the wrong value" confusion in CI, where a leftover TF_VAR_instance_type from a previous pipeline step
can silently override what a file says.
Complex Types and Optional Object Attributes#
Beyond primitive string/number/bool, HCL's type system supports collection types (list, set,
map) and structural types (object, tuple) — and object type constraints support optional()
attributes, which is what makes a genuinely reusable module's variable interface both strict and ergonomic
at the same time.
variable "subnet_config" {
type = list(object({
cidr_block = string
availability_zone = string
public = optional(bool, false)
tags = optional(map(string), {})
}))
}Without optional(), every caller of this module would be forced to specify public and tags for every
single subnet object, even when the default (false, an empty map) is what nearly every caller wants —
optional(bool, false) says "this attribute may be omitted, and if it is, use false." This single feature
is why well-designed public modules (the terraform-aws-modules family referenced in the registry section
below) can expose dozens of configuration knobs without making every caller's code enormous.
| Type | Shape | Typical use |
|---|---|---|
list(T) | Ordered, allows duplicates | An ordered sequence where position matters (subnet CIDR order) |
set(T) | Unordered, no duplicates | A collection where only membership matters, feeding for_each |
map(T) | Key-value pairs | Tags, or any lookup-by-name structure |
object({...}) | Fixed, named attributes, each own type | A structured configuration value — the "shape" of one thing |
tuple([T1, T2, ...]) | Fixed-length, each position own type | Rare — a genuinely fixed-arity value, like [string, number] |
Tip
Best practice: prefer object({...}) with named, typed, and (where sensible) optional() attributes
over a loosely-typed any or map(any) variable for any input with real internal structure. The stricter
type catches a caller's typo (publci = true instead of public = true) at terraform validate time as a
clear type-mismatch error, instead of the value silently being ignored the way an unvalidated map would
allow.
Marking Sensitive Values#
A variable or output marked sensitive = true gets its value redacted from CLI plan/apply output and
from terraform show — but this is display-layer redaction only, not encryption, and confusing the two is a
real security gap.
variable "db_password" {
type = string
sensitive = true
}
output "db_connection_string" {
value = "postgres://app:${var.db_password}@${aws_db_instance.checkout.endpoint}/checkout"
sensitive = true
}With sensitive = true, a terraform plan referencing var.db_password prints (sensitive value) instead
of the literal string — real protection against a password ending up in a CI log or a screen-share. What it
does not do: encrypt the value inside the state file. State (Part 2) stores every attribute in plain
JSON by default, sensitive-marked or not — db_connection_string is fully readable by anyone with read
access to the state backend. The actual fix for secrets is keeping them out of Terraform state entirely
(a secrets manager reference resolved at runtime by the application, not baked into a Terraform-managed
attribute) or using a state backend with encryption at rest and tightly scoped IAM read access — sensitive
is a blast-radius reduction for accidental log/screen exposure, not a substitute for either.
Warning
Never assume sensitive = true means a value is safe to store in Terraform state long-term. A leaked state
file (checked into git by accident, or an over-permissioned S3 bucket) exposes every attribute in plain
text regardless of this flag — Part 2 covers state-file encryption and access control as the actual control
that matters here.
The Plan/Apply Workflow, Step by Step#
terraform init, plan, and apply are three genuinely distinct phases, and understanding what each one
does internally — not just what to type — is what lets you diagnose a stuck or confusing run instead of
re-running commands and hoping.
The refresh step is the one most engineers underestimate. Before computing a diff, Terraform (by
default) queries the real state of every tracked resource through its provider — not just trusting what's
recorded in the state file. This is exactly how Terraform detects drift (Part 6): if someone manually changed
a security group rule in the AWS console, the refresh step notices the live value no longer matches the
recorded state, and the resulting plan will show that as a change to reconcile, even though your .tf files
never changed.
terraform apply then walks the dependency graph, executing as much as it safely can in parallel — resources
with no dependency relationship between them apply concurrently (bounded by -parallelism, default 10),
while dependent resources wait their turn. This is why a plan for 40 unrelated resources typically finishes
much faster than 40 times a single resource's apply time.
Warning
terraform apply -auto-approve skips the human review step entirely — appropriate for a CI pipeline stage
that already gated on a reviewed plan output (Part 8), never appropriate as a habit for local, ad hoc
changes against a shared environment. A team that normalizes -auto-approve locally loses the one point
in the whole workflow where a human actually reads "1 to add, 0 to change, 1 to destroy" before it
happens.
Saving and Applying an Exact Plan File#
terraform plan -out=tfplan saves the computed plan to a binary file; terraform apply tfplan then
applies exactly that plan, with no re-computation — this is the difference between "apply what I reviewed"
and "apply whatever the current state of the world produces right now," and the gap between the two is a
real source of surprise applies.
terraform plan -out=tfplan
# a human, or a CI gate, reviews the plan output
terraform apply tfplanWithout a saved plan file, a bare terraform apply re-runs the entire plan phase — including the refresh
step — immediately before applying. If anything about the real infrastructure changed between when a human
reviewed a plan and when apply actually ran (someone else's apply landed first, a resource was manually
edited), the applied plan can differ from the one that was reviewed, silently. A saved plan file freezes the
reviewed plan exactly as computed; apply on that file fails outright, rather than silently improvising, if
the underlying state has moved on since the plan was generated.
Important
This is the mechanism every serious CI/CD pipeline for Terraform is built around (Part 8 goes deep on the
full pipeline): plan and save an artifact in one job, post its human-readable output for review, then apply
that exact artifact in a separate, gated job — never re-running plan implicitly as part of apply in an
automated pipeline.
The Dependency Graph — How Terraform Decides Ordering#
Every plan and apply is driven by a directed acyclic graph Terraform builds by parsing every resource
reference in your configuration — you can inspect it directly with terraform graph.
terraform graph | dot -Tsvg > graph.svgThree sources feed the graph, in order of how explicit they are: implicit references (aws_s3_bucket.logs.id
used inside another resource, as shown earlier), the depends_on meta-argument for dependencies Terraform
can't infer from an expression (a resource that must exist first purely for a side effect, with no attribute
actually consumed), and provider-internal ordering rules the plugin itself enforces.
resource "aws_iam_role_policy_attachment" "logs" {
role = aws_iam_role.checkout.name
policy_arn = aws_iam_policy.logs_write.arn
}
resource "aws_lambda_function" "checkout_processor" {
# No attribute of the role/policy attachment is referenced directly here,
# but IAM permissions must exist before the function invocation path
# depends on them — depends_on makes that explicit.
depends_on = [aws_iam_role_policy_attachment.logs]
# ...
}Tip
Best practice: reach for depends_on only when no real attribute reference exists to create the
dependency implicitly — an explicit depends_on you didn't actually need is a common source of
unnecessarily serialized (slower) applies, and a sign the configuration should instead reference an
attribute of the resource it depends on.
count, for_each, and Dynamic Blocks#
count and for_each both create multiple instances of a resource or module from one block, but they
address instances differently, and picking the wrong one is one of the most common sources of an
unexpectedly destructive plan.
# count: instances are addressed by numeric index
resource "aws_instance" "worker" {
count = 3
ami = data.aws_ami.app.id
instance_type = "t3.micro"
tags = { Name = "checkout-worker-${count.index}" }
}
# addresses: aws_instance.worker[0], [1], [2]
# for_each: instances are addressed by a stable key
resource "aws_instance" "worker" {
for_each = toset(["a", "b", "c"])
ami = data.aws_ami.app.id
instance_type = "t3.micro"
tags = { Name = "checkout-worker-${each.key}" }
}
# addresses: aws_instance.worker["a"], ["b"], ["c"]Removing the middle element of a count-indexed list of 3 doesn't just remove that one instance — every
instance after it shifts index, so Terraform sees worker[1] and worker[2] as changed identities and
plans to destroy and recreate them, even though conceptually only one instance actually needs to go.
for_each's key-based addressing doesn't have this problem: removing key "b" only ever touches
worker["b"].
Dynamic blocks solve a related but different problem — generating a variable number of nested blocks (not whole resources) inside one resource, such as a variable number of ingress rules on a security group:
resource "aws_security_group" "checkout" {
name = "checkout-sg"
dynamic "ingress" {
for_each = var.allowed_ports
content {
from_port = ingress.value
to_port = ingress.value
protocol = "tcp"
cidr_blocks = ["10.0.0.0/16"]
}
}
}Tip
Best practice: default to for_each for anything with a natural key (a name, a region, a team) and
reserve count for genuinely disposable, order-independent, all-identical resources, or the common
count = var.enable_feature ? 1 : 0 pattern for conditionally creating a single resource.
The lifecycle Meta-Argument#
lifecycle changes how Terraform manages a specific resource's create/update/destroy behavior, and three
of its arguments matter enough to know cold.
resource "aws_db_instance" "checkout" {
# ...
lifecycle {
prevent_destroy = true
create_before_destroy = true
ignore_changes = [tags["LastModifiedBy"]]
}
}prevent_destroy = truemakesterraform destroy(or any plan implying destruction of this resource) fail outright — a hard safety rail for anything genuinely catastrophic to lose, like a production database.create_before_destroy = trueflips the default destroy-then-create order to create-then-destroy for a replacement — essential for anything where a moment of complete absence is unacceptable (an ASG's launch template, a load balancer target group).ignore_changestells Terraform to stop treating drift on specific attributes as something to reconcile — useful for attributes a different system legitimately manages after creation (an autoscaler adjustingdesired_capacity, a tagging tool adding aLastModifiedBytag).
Warning
ignore_changes = [all] silences drift detection for the entire resource, not just one attribute — it's
occasionally the right call for a resource genuinely co-managed by another tool, but reaching for it to make
an annoying diff go away, without understanding why the diff exists, is how real drift (Part 6) goes
unnoticed for months.
The Terraform Registry — Discovering Providers and Modules#
The public Terraform Registry (registry.terraform.io) hosts both providers and reusable modules, and
source addresses resolve differently for each — worth knowing cold before Part 3 goes deep on writing your
own modules.
# Provider source: hostname (implied registry.terraform.io if omitted) / namespace / type
terraform {
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.60" }
}
}
# Module source: registry namespace/name/provider, OR a git URL, OR a local path
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
# ...
}
module "internal_service" {
source = "git::https://github.com/example-org/terraform-modules.git//service?ref=v2.3.1"
}| Source form | Resolves via | Typical use |
|---|---|---|
hashicorp/aws | Public registry, provider namespace | Any public provider |
terraform-aws-modules/vpc/aws | Public registry, module namespace/name/provider | A well-maintained, widely-used community module |
git::https://...//path?ref=v2.3.1 | Direct git clone, pinned to a tag/commit | An internal, private module not published to a registry |
./modules/network | Local filesystem, relative to the caller | A module that only ever makes sense inside this one repo |
app.terraform.io/org/module/provider | HCP Terraform's private registry | An internal module published for org-wide reuse with real versioning |
Tip
Best practice: for a public community module like terraform-aws-modules/vpc/aws, always pin version
to a specific range and read the module's own changelog before bumping it — these modules can carry
significant blast radius (an unreviewed major-version bump to a VPC module has genuinely taken down shared
networking for an entire org before), and they deserve the same version-bump scrutiny as a provider, not a
"just take latest" default.
The Everyday Toolkit: fmt, validate, console#
Three commands earn a permanent place in your muscle memory, distinct from the plan/apply cycle:
terraform fmt -recursive # canonical formatting, in place
terraform validate # syntax + internal reference checks, no credentials needed
terraform console # interactive REPL for testing expressions against real stateterraform console is underused — it loads your current state and configuration into an interactive prompt
where you can evaluate any expression, which is the fastest way to confirm a cidrsubnet() call or a complex
for expression produces what you expect, before committing it to a resource block:
> cidrsubnet("10.0.0.0/16", 8, 4)
"10.0.4.0/24"
> [for s in aws_subnet.private : s.id]
[
"subnet-0a1b2c3d",
"subnet-0e4f5g6h",
]
Documenting Configuration with terraform-docs#
terraform-docs generates a module's input/output reference directly from its variable and output
blocks — the single highest-leverage documentation tool in the ecosystem, because the docs it produces can
never drift from the actual code the way hand-maintained documentation does.
terraform-docs markdown table --output-file README.md --output-mode inject .Run against the network-foundation module from the next section, it produces a table listing every variable
(name, type, description, default, required/optional) and every output, straight from the description
arguments already in the code — which is itself a strong argument for never skipping a description on a
variable or output block, even for an internal module nobody outside the team will read: the description
is the only source terraform-docs has to work with.
| Tool | Job |
|---|---|
terraform fmt | Canonical whitespace/formatting |
terraform validate | Syntax and internal reference validity |
terraform-docs | Auto-generated input/output reference |
terraform console | Interactive expression testing against real state |
Tip
Best practice: wire terraform-docs into a pre-commit hook (via the pre-commit-terraform project,
which also wraps fmt, validate, and tflint from Part 7 into one hook chain) rather than a manual step
— a README that's regenerated automatically on every commit touching variables.tf never has the chance to
go stale.
Worked Scenario: Provisioning checkout-service's Network Foundation#
The platform team's first real Terraform module provisions the VPC checkout-service and its siblings run
in: one VPC, three public and three private subnets across three availability zones, a NAT gateway per AZ,
and route tables wiring it all together.
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
tags = { Name = "${local.name_prefix}-vpc" }
}
resource "aws_subnet" "private" {
for_each = { for idx, az in local.azs : az => idx }
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(aws_vpc.main.cidr_block, 8, each.value)
availability_zone = each.key
tags = { Name = "${local.name_prefix}-private-${each.key}" }
}Running terraform plan for the first time against an empty state shows 23 resources to add, 0 to change, 0 to destroy — the expected shape of a brand-new environment's first apply. The team applies it,
commits the state to the remote backend covered in Part 2, and this VPC becomes the foundation every later
chapter's examples build on.
Note
This scenario intentionally uses for_each with a map built from local.azs, not count — a direct
application of the count-vs-for_each guidance above, chosen so a future change to the AZ list (adding a
fourth AZ, or reordering the existing three) never causes an unrelated subnet to be destroyed and
recreated.
Worked Scenario: the PR a Reviewer Should Have Rejected#
Three months later, an engineer on the catalog-service team opens a PR to "clean up" the security group
module, restructuring an aws_security_group_rule block from an explicit resource per rule into a single
aws_security_group with inline ingress/egress blocks — a genuinely reasonable refactor on its face.
Terraform will perform the following actions:
# aws_security_group_rule.catalog_ingress_http will be destroyed
# aws_security_group_rule.catalog_ingress_https will be destroyed
# aws_security_group.catalog will be updated in-place
~ ingress = [
+ {
+ cidr_blocks = ["10.0.0.0/16"]
+ from_port = 80
...
},
]
Plan: 0 to add, 1 to change, 2 to destroy.
The plan output is the whole story: for a brief window between the two rule resources being destroyed and
the new inline block being applied, catalog-service's security group would have had zero ingress rules
— a real availability gap, not a cosmetic diff, because AWS security groups don't guarantee atomic replacement
across resource-type boundaries within one apply. The reviewer, reading the plan output rather than just the
diff of the .tf file, caught it and asked for the change to be split into an additive step (add the inline
rules alongside the old ones) followed by a separate cleanup PR once the new rules were confirmed live — never
combining "add new" and "remove old" for anything on the request path in a single apply.
Important
A .tf diff that looks like harmless refactoring can still produce a destructive plan. Reviewing the
code change is necessary but not sufficient — reviewing the actual terraform plan output (via a CI
comment, per Part 8) is what catches this class of problem, because Terraform's destroy/create decisions
depend on resource type and address identity, not on whether a human would call two configurations
"the same thing."
Worked Scenario: count vs. for_each, and Getting Bitten by It#
Before the team standardized on for_each, an earlier version of the inventory-service module provisioned
three read-replica databases with count = 3. Six months in, someone needed to remove the middle replica
specifically (it was misconfigured with the wrong instance class from an earlier mistake) — a one-line change,
count = 3 to a filtered list dropping index 1, seemed straightforward.
The resulting plan showed 2 to destroy, 2 to create — not the 1 to destroy anyone expected. Removing
index 1 shifted index 2 down to become the new index 1, and Terraform, addressing purely by index, saw that
as "the old index-1 replica is gone, and a brand-new index-1 replica with different attributes needs to be
created" — a full destroy-and-recreate of a database Terraform had no reason to believe needed replacing at
all. The team caught it in the plan review (the same discipline as the previous scenario) before it reached
apply, migrated the module to for_each keyed by replica purpose ("analytics", "reporting",
"backup") using the moved block mechanics covered in Part 6, and has not had an index-shift incident
since.
Part 1 CLI Cheat Sheet#
| Command | Purpose |
|---|---|
terraform init | Download providers/modules, initialize the backend |
terraform fmt -recursive | Canonical formatting across the whole directory tree |
terraform validate | Syntax and internal reference check, no credentials required |
terraform plan -out=tfplan | Compute and save an exact, reviewable plan |
terraform apply tfplan | Apply exactly the saved plan, no re-computation |
terraform apply -auto-approve | Apply without an interactive confirmation prompt — CI use only |
terraform console | Interactive REPL for testing expressions against real state |
terraform graph | Print the dependency graph in DOT format |
terraform show | Human-readable dump of the current state or a saved plan file |
terraform providers | List every provider required by the current configuration |
tenv tf use <version> | Switch the active Terraform version for this project |
Common Mistakes and Interview Traps#
| Mistake | Why it's wrong | Correct approach |
|---|---|---|
Treating terraform apply output as the thing to review | The plan already told you exactly what will happen — reviewing after apply means the damage, if any, is already done | Review terraform plan output (ideally in a PR comment) before every apply, every time |
Assuming count and for_each are interchangeable | count addresses by index, for_each by key — removing a middle item behaves completely differently between the two | Default to for_each for anything with a natural key; reserve count for disposable, order-independent resources |
Renaming a resource's local name without a moved block | Terraform treats a new address as a new resource — the old one gets destroyed even if nothing about the real infrastructure changed | Use a moved block (Part 6) whenever a resource's address changes for any reason |
Reaching for depends_on by default | It serializes applies unnecessarily and hides the real reason for the dependency from a future reader | Prefer an implicit reference (using the dependency's own attribute); reserve depends_on for true side-effect-only dependencies |
Running -auto-approve out of habit, locally | Skips the one human checkpoint in the entire workflow | Reserve -auto-approve for CI stages that already gated on a reviewed plan |
Not pinning required_version/provider versions | Two engineers on different CLI or provider versions can get different plans for identical code | Pin both, and treat a version bump as a deliberate, reviewed change |
Worked Practice Problems#
Problem 1: A .tf file changes a resource's local name from aws_instance.web to aws_instance.app,
with every other argument identical. What does terraform plan show, and why?
Answer: 1 to add, 1 to destroy (or "replace" framed as those two lines) — Terraform tracks resources by
address, not by their arguments' similarity. A changed local name is, from Terraform's perspective, an
entirely new resource address with no relationship to the old one, so it plans to destroy the old address and
create the new one, even though the underlying instance configuration never changed. A moved block (Part 6)
is the fix that avoids this.
Problem 2: A team has for_each = toset(["us-east-1", "us-west-2"]) provisioning one S3 bucket per
region. A third region, "eu-west-1", is added to the set. What does the plan show for the two existing
buckets, and why does this differ from what count = 3 would have shown for the same change?
Answer: 0 to change for the two existing buckets — for_each addresses instances by their key
("us-east-1", "us-west-2"), and adding a new key doesn't touch the identity of existing keys at all. Only
1 to add appears, for the new "eu-west-1" key. If this had been count-based instead, appending a third
item to an ordered list is actually the one count growth pattern that's usually safe (new items land at the
new highest index) — but any removal or reordering of earlier items would have shifted every later
index and triggered unnecessary destroy/recreate churn, which is exactly the difference this problem is
testing.
Problem 3: A resource has lifecycle { create_before_destroy = true } and is about to be replaced. What
problem does this prevent, and name one type of resource where skipping it would be a real production risk.
Answer: It prevents a window of complete absence between the old resource being destroyed and the new one being created — without it, Terraform's default order is destroy-then-create, meaning the replacement doesn't exist at all for however long the destroy and create calls take. A load balancer's launch template or target group is a concrete example: destroying it first, before the replacement exists, would mean new instances have nowhere to register and health checks fail during the gap.
Summary and What's Next#
Terraform's core model is smaller than it looks once the pieces connect: HCL blocks declare desired state,
providers translate that state into real API calls, resource addresses (not resource names on the target
platform) are what state and the dependency graph actually track, and every plan/apply is driven by a
graph built purely from the references in your configuration. count vs. for_each, depends_on, and
lifecycle are the levers that change how that graph gets built and executed — each with a specific, narrow
job, not interchangeable defaults. The ecosystem context matters too: which tool (Terraform or OpenTofu),
which pricing tier, and which policy engine you pick are live decisions in 2026, not settled defaults.
Everything in this chapter assumed state "just works" in the background. Part 2 opens that box: what a state
file actually contains, why it's the single most dangerous file in a Terraform project to mishandle, how
remote backends and locking prevent two people from corrupting it simultaneously, and the concrete incident
patterns — a terraform destroy against the wrong workspace, a state file with a leaked secret in it, a lock
that never releases — that make state management its own dedicated chapter.