# Terraform & Infrastructure as Code — Part 1: Fundamentals, HCL & the Plan/Apply Workflow

> **Series:** Terraform & Infrastructure as Code (1 of 9)
> **Part 1:** This file — Fundamentals, HCL & the Plan/Apply Workflow
> **Part 2:** `02-state-management-and-remote-backends.md` — State Management & Remote Backends
> **Part 3:** `03-modules-and-reusable-design.md` — Modules & Reusable Infrastructure Design
> **Part 4:** `04-workspaces-and-environments.md` — Workspaces, Environments & Multi-Account Patterns
> **Part 5:** `05-providers-data-sources-and-provisioners.md` — Providers, Data Sources & Provisioners Deep Dive
> **Part 6:** `06-drift-detection-import-and-refactoring.md` — Drift Detection, Import & Refactoring Existing Infrastructure
> **Part 7:** `07-testing-terraform.md` — Testing Terraform: Static Analysis, the Native Test Framework & Terratest
> **Part 8:** `08-cicd-for-terraform.md` — CI/CD for Terraform: Pipelines, Gates & GitOps for Infrastructure
> **Part 9:** `09-governance-cost-and-multi-cloud-at-scale.md` — Terraform at Team Scale: Governance, Cost & Multi-Cloud Patterns
> **Questions:** `questions.md`

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](/tutorials/automation-cicd-gitops/02-infrastructure-as-code) first.
This series picks up from there and goes to full production depth on one tool.

## Table of Contents

1. [Why Terraform Gets Its Own Deep-Dive Series](#why-terraform-gets-its-own-deep-dive-series)
2. [The Terraform Ecosystem in 2026 — HashiCorp, IBM, and the OpenTofu Fork](#the-terraform-ecosystem-in-2026--hashicorp-ibm-and-the-opentofu-fork)
3. [Installing and Version-Pinning Terraform](#installing-and-version-pinning-terraform)
4. [Version Constraint Operators, and Why `~>` Is the Default](#version-constraint-operators-and-why--is-the-default)
5. [HCL — Blocks, Arguments, and Expressions](#hcl--blocks-arguments-and-expressions)
6. [Providers and the Plugin Model](#providers-and-the-plugin-model)
7. [Resources — the Core Building Block](#resources--the-core-building-block)
8. [Variables, Locals, and Outputs](#variables-locals-and-outputs)
9. [Complex Types and Optional Object Attributes](#complex-types-and-optional-object-attributes)
10. [Marking Sensitive Values](#marking-sensitive-values)
11. [The Plan/Apply Workflow, Step by Step](#the-planapply-workflow-step-by-step)
12. [Saving and Applying an Exact Plan File](#saving-and-applying-an-exact-plan-file)
13. [The Dependency Graph — How Terraform Decides Ordering](#the-dependency-graph--how-terraform-decides-ordering)
14. [count, for_each, and Dynamic Blocks](#count-for_each-and-dynamic-blocks)
15. [The `lifecycle` Meta-Argument](#the-lifecycle-meta-argument)
16. [The Terraform Registry — Discovering Providers and Modules](#the-terraform-registry--discovering-providers-and-modules)
17. [The Everyday Toolkit: fmt, validate, console](#the-everyday-toolkit-fmt-validate-console)
18. [Documenting Configuration with terraform-docs](#documenting-configuration-with-terraform-docs)
19. [Worked Scenario: Provisioning checkout-service's Network Foundation](#worked-scenario-provisioning-checkout-services-network-foundation)
20. [Worked Scenario: the PR a Reviewer Should Have Rejected](#worked-scenario-the-pr-a-reviewer-should-have-rejected)
21. [Worked Scenario: count vs. for_each, and Getting Bitten by It](#worked-scenario-count-vs-for_each-and-getting-bitten-by-it)
22. [Part 1 CLI Cheat Sheet](#part-1-cli-cheat-sheet)
23. [Common Mistakes and Interview Traps](#common-mistakes-and-interview-traps)
24. [Worked Practice Problems](#worked-practice-problems)
25. [Summary and What's Next](#summary-and-whats-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.

```mermaid
timeline
    title Terraform's licensing and ownership timeline
    2023-08 : HashiCorp relicenses Terraform to BSL 1.1
    2023-09 : OpenTofu fork announced, MPL 2.0 retained
    2024 : OpenTofu 1.7-1.8 ship state encryption, early variable evaluation
    2025-02 : IBM completes $6.4B HashiCorp acquisition
    2026-03 : HCP Terraform legacy Free plan reaches end of life
    2026 : OpenTofu ships provider for_each and -exclude, features Terraform OSS lacks
```

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.

```hcl
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:

```bash
tenv tf install 1.15.8
tenv tf use 1.15.8
terraform version
```

> [!NOTE]
> `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.**

```hcl
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:

```mermaid
mindmap
  root((HCL top-level<br/>block types))
    Infrastructure
      resource
      data
    Reuse
      module
      variable
      output
    Configuration
      terraform
      provider
      locals
    Testing
      check
      run
```

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.

```hcl
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.

```mermaid
sequenceDiagram
    participant Core as Terraform core
    participant Provider as aws provider plugin
    participant API as AWS API

    Core->>Provider: "Create this aws_instance<br/>with these arguments"
    Provider->>API: RunInstances API call
    API-->>Provider: Instance ID, ARN, computed attributes
    Provider-->>Core: Resource state (all attributes)
    Core->>Core: Write resource into state file
```

**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):

```hcl
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.

```hcl
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).**

```hcl
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.**

```hcl
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.**

```hcl
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.**

```mermaid
flowchart TD
    Init["terraform init:<br/>download providers,<br/>initialize backend,<br/>lock provider versions"] --> Plan{"terraform plan"}
    Plan --> Read["Read current state file"]
    Read --> Refresh["Refresh: query real<br/>infrastructure via<br/>providers for current<br/>attribute values"]
    Refresh --> Diff["Diff refreshed state<br/>against desired config"]
    Diff --> PlanOut["Produce a plan: create /<br/>update-in-place / destroy<br/>/ replace, per resource"]
    PlanOut --> Review{"Human or CI<br/>reviews the plan"}
    Review -->|"Approved"| Apply["terraform apply:<br/>execute the plan via<br/>provider CRUD calls,<br/>respecting the<br/>dependency graph"]
    Review -->|"Rejected"| Stop["Fix config,<br/>plan again"]
    Apply --> Write["Write new state<br/>to the backend"]

    classDef action fill:#e5f0fa,stroke:#1d6fb8,color:#10161c
    classDef gate fill:#fbeee0,stroke:#b8650f,color:#10161c
    classDef done fill:#e5f5ea,stroke:#1f8a4c,color:#10161c
    class Init,Read,Refresh,Diff,PlanOut,Apply action
    class Plan,Review gate
    class Write done
```

**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.**

```bash
terraform plan -out=tfplan
# a human, or a CI gate, reviews the plan output
terraform apply tfplan
```

Without 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`.**

```bash
terraform graph | dot -Tsvg > graph.svg
```

Three 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.

```hcl
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.**

```hcl
# 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"]`.

```mermaid
quadrantChart
    title Choosing count vs. for_each
    x-axis Homogeneous items --> Distinct, named items
    y-axis Order/index doesn't matter --> Stable identity matters
    quadrant-1 for_each, clearly
    quadrant-2 for_each with a map
    quadrant-3 count, fine either way
    quadrant-4 count works, but for_each is safer
    "3 identical, disposable workers": [0.2, 0.2]
    "One resource per named region": [0.9, 0.8]
    "One IAM policy per named team": [0.75, 0.85]
    "A fixed-size numbered batch job": [0.15, 0.3]
```

**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:

```hcl
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.**

```hcl
resource "aws_db_instance" "checkout" {
  # ...
  lifecycle {
    prevent_destroy       = true
    create_before_destroy = true
    ignore_changes         = [tags["LastModifiedBy"]]
  }
}
```

- **`prevent_destroy = true`** makes `terraform 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 = true`** flips 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_changes`** tells Terraform to stop treating drift on specific attributes as something to
  reconcile — useful for attributes a different system legitimately manages after creation (an autoscaler
  adjusting `desired_capacity`, a tagging tool adding a `LastModifiedBy` tag).

> [!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.**

```hcl
# 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:

```bash
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 state
```

`terraform 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.**

```bash
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.

```hcl
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.
