Learn Terraform
Understand the Machine, Not Just the Syntax

Terraform becomes easy to type long before it becomes safe to operate. This course is designed around the question that causes many real incidents: "Why does Terraform want to destroy that?"

Terraform is a graph planner and stateful ownership system that manages durable associations between resource addresses in configuration and object identities returned by providers.
By the end of this course you will be able to look at a plan and explain every action, every dependency, and every risk.

How to study this guide

Terraform rewards prediction. At each Pause and predict box:

  1. stop reading;
  2. say the expected plan out loud;
  3. include resource addresses, not just resource names;
  4. decide whether each action is create, update, replace, destroy, read, or no-op;
  5. continue and compare your model with the explanation.

The final workshop uses only HashiCorp's random and local providers. It creates names and local JSON files rather than paid cloud infrastructure, but it still exercises real provider installation, resource identity, state, drift, imports, tests, refactoring, and destruction.

Examples use the Terraform language's native HCL syntax. Commands assume Terraform CLI 1.7 or later unless a feature is explicitly marked newer.


Foundation

The Map You Will Carry

Terraform always operates across three different worlds:

flowchart LR
    C["CONFIGURATION\nwhat your .tf files describe"]
    S["STATE\nTerraform's durable memory"]
    R["REMOTE REALITY\nobjects behind provider APIs"]

    C -->|"desired arguments\nand relationships"| P["PLAN"]
    S -->|"prior identities\nand last observations"| P
    R -->|"fresh reads through providers"| P
    P -->|"proposed actions"| A["APPLY"]
    A -->|"API calls"| R
    A -->|"new identities and values"| S
    

These worlds are related, but they are not interchangeable.

WorldContainsDoes not guarantee
ConfigurationBlocks, arguments, expressions, desired relationshipsThat objects already exist
StateResource-address mappings, provider IDs, observed attributes, outputsThat remote reality is still unchanged
Remote realityActual cloud, SaaS, network, or local objectsThat Terraform knows or owns them

A useful approximation is:

plan =
  provider-aware difference(
    evaluated configuration,
    refreshed objects identified by prior state
  )

The provider schema and lifecycle rules decide whether a difference is an in-place update or a replacement. The dependency graph decides ordering. Unknown values delay parts of the answer until apply.

This model is more important than any individual command.

Core Insight

Opening Puzzle — The Rename That Can Delete a Server

Imagine this configuration already created a production server:

resource "example_server" "api" {
  name = "production-api"
  size = "small"
}

Its Terraform address is:

example_server.api

Someone improves the local label:

resource "example_server" "application" {
  name = "production-api"
  size = "small"
}

No remote argument changed. The real server's name is still production-api.

Pause and predict

Will Terraform see:

  • a harmless code-only rename;
  • an in-place update;
  • no change;
  • one destroy and one create?

Terraform normally sees one address removed and another address added:

- example_server.api          destroy
+ example_server.application  create

Why? Terraform does not identify the object by visually comparing every argument and guessing that two blocks "look like the same server." Its state associates a remote provider ID with a resource address.

Before the edit:

example_server.api  ──owns──>  provider object srv-91f2

After the edit, the address example_server.api is absent from configuration, so its object is no longer desired there. example_server.application is a new address with no object attached.

The safe refactor records intent:

moved {
  from = example_server.api
  to   = example_server.application
}

Now Terraform moves the state association before planning changes:

example_server.application  ──owns──>  provider object srv-91f2
Terraform manages durable associations between resource addresses in configuration and object identities returned by providers. Everything else in this guide grows from that sentence.
Expedition One

Feel One Complete Terraform Run

We begin with a resource that changes no external infrastructure. The built-in terraform_data resource follows Terraform's normal resource lifecycle and stores an input value in state.

Create an empty directory containing main.tf:

terraform {
  required_version = ">= 1.7.0, < 2.0.0"
}

variable "learner" {
  type        = string
  description = "Name included in the lesson record."
  default     = "Ada"
}

resource "terraform_data" "lesson" {
  input = {
    message = "Terraform remembers identity"
    learner = var.learner
  }
}

output "lesson_record" {
  value = terraform_data.lesson.output
}

No provider block is required. terraform_data comes from a provider built into Terraform.

Step 1 — initialize the working directory

terraform init

init prepares the directory. Depending on the configuration, it: initializes the selected state backend; downloads child modules; installs provider plugins; reads or creates the dependency lock file; creates local working data under .terraform/. It does not normally create the declared infrastructure.

Step 2 — normalize and validate

terraform fmt
terraform validate

fmt rewrites Terraform files into canonical style. validate checks syntax, types, references, and internal consistency. It does not prove that credentials work, that a remote API accepts the request, or that quotas are available.

Step 3 — plan

terraform plan

The first plan should show:

+ terraform_data.lesson

Plan: 1 to add, 0 to change, 0 to destroy.

The resource ID is shown as id = (known after apply). Terraform knows a resource will exist, but the provider/runtime has not created it and returned its final ID. This is an unknown value, not null, not an empty string, and not a random placeholder.

Step 4 — apply exactly what you reviewed

terraform plan -out=tfplan
terraform show tfplan
terraform apply tfplan

The saved plan is a machine-readable artifact. Applying it executes the actions in that plan rather than silently calculating a fresh one. Do not commit tfplan — saved plans can contain full configuration values, backend details, and sensitive data.

Step 5 — inspect the result from several angles

terraform output
terraform state list
terraform state show terraform_data.lesson
terraform show
CommandQuestion
terraform outputWhat interface values does the root module expose?
terraform state listWhich addresses are tracked?
terraform state show ADDRESSWhat is recorded for one tracked object?
terraform showWhat is the current state snapshot in human-readable form?

Step 6 — prove idempotence

terraform plan

If configuration, state, and observed reality agree:

No changes. Your infrastructure matches the configuration.

Idempotence does not mean "Terraform commands never change anything." It means repeatedly applying the same desired model converges to a stable result.

Step 7 — change and predict

Change:

message = "State connects addresses to objects"
Pause and predict

Will the resource update or be replaced?

For terraform_data.input, Terraform can plan an in-place update:

~ terraform_data.lesson

Now change triggers_replace:

triggers_replace = "revision-2"

Changing triggers_replace causes replacement because that is the documented semantics of the argument. Whether a normal provider argument updates or replaces an object comes from provider schema and planning logic, not from the visual size of the change.

Step 8 — destroy

terraform plan -destroy
terraform destroy

destroy is essentially a planning mode in which the desired configuration contains none of the managed resource instances. It destroys what the current state tracks—not every object in the account.

What the first run secretly taught you

You have already encountered the entire machine:

sequenceDiagram
    autonumber
    participant U as You
    participant T as Terraform Core
    participant B as Backend
    participant P as Provider

    U->>T: terraform plan
    T->>B: lock and read state
    T->>T: load configuration and graph
    T->>P: read existing objects
    P-->>T: refreshed values
    T->>P: plan proposed changes
    P-->>T: update or replace decisions
    T-->>U: execution plan
    U->>T: terraform apply saved-plan
    T->>B: acquire lock and verify state
    T->>P: create/update/delete calls
    P-->>T: IDs and final attributes
    T->>B: persist new state
    T->>B: unlock
    

Terraform is not a continuously running controller. When the command ends, Terraform normally exits. If drift happens an hour later, no local Terraform daemon repairs it. The next plan, apply, or external drift-detection run must observe it.

TerraformKubernetes
Runs on demand and exitsControllers run continuously
Manages resources through many provider APIsReconciles Kubernetes API objects
Uses durable state to map addresses to provider objectsDesired/observed cluster state lives in Kubernetes
A plan is a reviewable proposed transactionControllers continually converge without a human plan
Both are declarative. Declarative does not imply continuous.
Expedition Two

How a Plan Is Actually Formed

A plan is not merely a text diff between old.tf and new.tf. It is a proposed transition built from configuration, state, live reads, provider semantics, and graph constraints.

The three comparisons

flowchart TD
    PS["Prior state\naddress → provider ID + cached attributes"]
    LIVE["Provider reads live object\nby provider ID"]
    REF["Refreshed state in memory"]
    CFG["Evaluated configuration"]
    SCHEMA["Provider schema and planning rules"]
    PLAN["Execution plan"]

    PS -->|"which object should I read?"| LIVE
    LIVE --> REF
    REF --> PLAN
    CFG --> PLAN
    SCHEMA --> PLAN
    

Case A — configuration changed

configuration: size = "large"
state/live:    size = "small"

Plan: update or replace, depending on provider behavior.

Case B — remote drifted

configuration: size = "small"
prior state:   size = "small"
live object:   size = "large"

After refresh, Terraform sees "large" and normally proposes returning the object to "small".

Case C — remote object disappeared

state: address points to object srv-91f2
provider read: object srv-91f2 not found
configuration: address still declared

Terraform normally proposes creating a replacement object.

Case D — unmanaged object exists

remote reality: object srv-77aa exists
configuration: describes a similar server
state: no association

Terraform normally plans a new object. It does not scan the entire account and adopt things that look similar. Adoption requires an import.

Provider schemas decide the meaning of arguments

Terraform Core understands graphs, values, addresses, state, and lifecycle. Providers understand remote resource types.

flowchart LR
    HCL["resource configuration"] --> CORE["Terraform Core"]
    CORE --> RPC["provider protocol"]
    RPC --> PROV["provider plugin"]
    PROV --> API["cloud / SaaS / local API"]
    API --> PROV --> CORE
    

Terraform itself does not know what an AWS subnet, a GitHub repository, or a Cloudflare DNS record means. The relevant provider does. This explains why: provider upgrades can change plans without HCL changes; one attribute updates in place while another forces replacement; API normalization can turn "Example" into "example"; eventual consistency can cause transient read errors; provider bugs can produce incorrect plans.

Unknown is a first-class value

resource "example_network" "main" {
  cidr = "10.0.0.0/16"
}

resource "example_server" "api" {
  network_id = example_network.main.id
}

Before the network exists, its ID is unknown. Therefore network_id is also unknown:

example_network.main.id = (known after apply)
example_server.api.network_id = (known after apply)
flowchart LR
    N["example_network.main"] -->|"id becomes known"| S["example_server.api"]
    

During apply, Terraform creates the network, receives its ID, then supplies that ID when creating the server.

ValueMeaning
"abc"Known string
""Known empty string
nullIntentionally absent; often lets provider/default logic decide
unknownA value will exist, but planning cannot yet know it
sensitiveValue exists but normal UI should redact it
ephemeralValue is intentionally omitted from state/plan where supported

Unknowns restrict graph shape

Terraform must know how many resource instances and their addresses during planning. This is invalid when the keys are unknown:

resource "example_server" "node" {
  for_each = toset(example_network.main.generated_subnet_ids)
  subnet_id = each.value
}

Design the keys from configuration-known values instead:

variable "subnets" {
  type = map(object({
    cidr = string
  }))
}

resource "example_subnet" "this" {
  for_each = var.subnets
  cidr     = each.value.cidr
}

resource "example_server" "node" {
  for_each  = var.subnets
  subnet_id = example_subnet.this[each.key].id
}

The plan is useful, not prophetic

A plan is computed from observations at a point in time. Between plan and apply: another operator may change state; a cloud object may drift; credentials may expire; quotas may be consumed; the API may reject a name; an external dependency may disappear.

"Given the configuration, state, live observations, provider versions, credentials, and API responses available during planning, these are the actions Terraform currently proposes." That is powerful—just not omniscient.

Read the symbols before the prose

SymbolMeaning
+Create
~Update in place
-Destroy
-/+Destroy, then create a replacement
+/-Create replacement, then destroy old object
<=Read a data source during apply

Plan modes are different questions

terraform plan                  # "What changes would make managed objects match configuration?"
terraform plan -destroy         # "What would be destroyed if the desired managed set were empty?"
terraform plan -refresh-only    # "What state/output updates would accept the live observations?"
terraform plan -replace='NAME'  # "What would happen if this instance were replaced?"
terraform plan -target='module' # "What partial plan is necessary for this selected address?"
Pause and predict — drift

Configuration and state both say replicas = 3. Someone changes the live object to replicas = 5. What will a normal plan do? What will plan -refresh-only do?

Normal plan refreshes to 5 and generally proposes changing reality back to 3. Refresh-only proposes updating state to record 5, without changing reality. Applying refresh-only accepts the out-of-band change into state, but the unchanged configuration still says 3. A later normal plan may therefore propose 3 again. To adopt the drift as desired, configuration must also change.

Checkpoint

The First Checkpoint

Before continuing, explain these without looking back:

Test yourself
  1. Why can a code-only resource label rename cause destruction?
  2. What information comes from configuration, state, and a provider read?
  3. Why does an unmanaged but similar object not prevent Terraform from creating one?
  4. What is the difference between null and unknown?
  5. Why must for_each keys be known during planning?
  6. What does a saved plan guarantee, and what can still change?
  7. Why is -target not a normal way to divide a large system?

If any answer feels vague, reread the opening puzzle and the three-world map. They are the foundation for everything that follows.

Language Workshop

HCL Builds Values and Relationships

Terraform configuration is not a sequence of API instructions. It is a collection of blocks whose expressions produce values and dependencies.

resource "example_network" "main" {
  cidr = var.network_cidr

  tags = merge(local.common_tags, {
    Name = "${var.project}-${var.environment}"
  })
}

Break it apart:

resource "example_network" "main" {
│        │                 │
│        │                 └─ local Terraform label
│        └─ provider-defined resource type
└─ block type

  cidr = var.network_cidr
  │      └─ expression producing a value and dependency
  └─ provider-defined argument
}

Files do not execute top to bottom

Terraform loads all top-level .tf and .tf.json files in one directory as a single module. File names and ordering are for humans. A block in z-last.tf can be an upstream dependency of a block in a-first.tf. Nested directories are separate modules and are not loaded automatically. Do not use filenames to model creation order. Use references.

The blocks and what they mean

BlockRole
terraformCLI version, provider requirements, backend/cloud settings
providerConfigures credentials, region, endpoint, or alias
variableInput contract of a module
localsNames reusable expressions inside a module
resourceDeclares lifecycle management of remote/logical objects
dataReads objects or computed information without owning lifecycle
moduleCalls another Terraform module
outputPublishes a module interface value
movedDeclares a resource-address refactor
importAssociates an existing object with an address
removedStops managing an address, optionally without destroying its object
checkEvaluates a non-blocking operational assertion

Values and types

KindExample
string"eu-west-2"
number3
booltrue
list["a", "b"]
tuple["a", 2, true]
settoset(["a", "b"])
map{ api = 2, worker = 4 }
object{ name = "api", size = 2 }
nullnull

Precise types turn assumptions into early errors and give module consumers a usable contract. Lists and sets are not interchangeable — a list has order and duplicates; a set has unique unordered membership. Converting to a set is ideal for for_each when each string is a stable identity.

Input variables are an API

Good input:

variable "environment" {
  type        = string
  description = "Deployment environment used in names and tags."

  validation {
    condition     = contains(["dev", "stage", "prod"], var.environment)
    error_message = "environment must be dev, stage, or prod."
  }
}

Every variable expands the module's public API and testing surface. Expose decisions that genuinely vary between callers; keep internal implementation details in locals. Do not put environment-specific values in reusable child-module defaults when doing so would surprise consumers.

Locals transform; they do not persist

locals {
  name_prefix = "${var.project}-${var.environment}"

  common_tags = {
    Project     = var.project
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

Locals give names to expressions, reduce duplication, create readable intermediate values, are recalculated during evaluation, are not input variables, and do not have their own state identity.

Outputs are module interfaces, not print statements

output "network" {
  description = "Network identity and subnet IDs for downstream modules."
  value = {
    id         = example_network.main.id
    subnet_ids = values(example_subnet.this)[*].id
  }
}

At the root module, outputs are stored in state and shown after apply. In a child module, outputs are how the caller receives selected values. Do not output an entire resource object merely because it is convenient. Publish a small, intentional interface.

Expressions create implicit dependencies

resource "example_subnet" "app" {
  network_id = example_network.main.id
  cidr       = "10.0.1.0/24"
}

The reference example_network.main.id does two jobs: passes a value and creates a graph edge. Terraform knows it must create/read the network before it can create the subnet. Prefer implicit dependencies because they carry the actual data relationship.

Use depends_on only for a real ordering relationship that carries no direct value. Overusing it makes the graph conservative, increases unknown values, and can serialize work unnecessarily.

Data sources read; resources own lifecycle

data "example_image" "base" {
  family = "approved-linux"
}

resource "example_server" "api" {
  image_id = data.example_image.base.id
}

The data source queries provider information but does not create/update/delete the selected image. It can still influence downstream replacements when its result changes. If arguments depend on unknown values, reads may be deferred until apply as <= read during apply.

for expressions reshape data

locals {
  public_services = {
    for name, service in var.services :
    name => service
    if service.public
  }
}

The syntax is a data transformation, not a loop that makes API calls. API operations come from resource and data instances created by the evaluated result.

Conditionals and dynamic blocks

locals {
  retention_days = var.environment == "prod" ? 365 : 14
}

resource "example_monitor" "critical" {
  count = var.environment == "prod" ? 1 : 0
  # address becomes example_monitor.critical[0]
}

Adding count changes the address shape. That matters during refactors. For many independently identified instances, prefer for_each.

dynamic blocks generate nested blocks inside one resource instance. Use it when the provider requires nested blocks and the repetition is real. Avoid clever nested dynamic structures that make the plan impossible to review.

Use the console to understand values

terraform console

> toset(["api", "worker", "api"])
> cidrsubnet("10.0.0.0/16", 8, 3)
> { for x in ["a", "bb"] : x => length(x) }
> jsonencode({ name = "api", replicas = 3 })
Core Concept

The Graph and the Identity Problem

Terraform translates evaluated blocks into a graph of resource instances:

flowchart LR
    VPC["example_network.main"]
    SA["example_subnet.app['a']"]
    SB["example_subnet.app['b']"]
    APIA["example_server.api['a']"]
    APIB["example_server.api['b']"]
    LB["example_load_balancer.public"]

    VPC --> SA --> APIA --> LB
    VPC --> SB --> APIB --> LB
    

Independent graph branches can run concurrently, subject to provider and CLI parallelism limits. Textual block order does not control it. The graph is why references are more than string interpolation.

Blocks and instances are different

One block:

resource "example_server" "api" {
  for_each = {
    blue  = "small"
    green = "large"
  }
  size = each.value
}

Two instances:

example_server.api["blue"]
example_server.api["green"]

State tracks instances, not just source blocks. This distinction matters for imports, replacements, state inspection, moved blocks, targeted recovery, and plan review.

count uses position as identity

variable "names" {
  default = ["api", "worker", "scheduler"]
}

resource "example_server" "service" {
  count = length(var.names)
  name  = var.names[count.index]
}

Addresses: example_server.service[0] (api), example_server.service[1] (worker), example_server.service[2] (scheduler).

Remove "worker" from the middle and Terraform still reasons by index: [0] remains api, [1] changes worker → scheduler, [2] is removed. Depending on provider semantics, this can update/replace the worker into a scheduler and destroy the former scheduler. Humans thought in names; state thought in indexes.

for_each uses keys as identity

variable "services" {
  default = {
    api       = "small"
    worker    = "medium"
    scheduler = "small"
  }
}

resource "example_server" "service" {
  for_each = var.services
  name = each.key
  size = each.value
}

Addresses: example_server.service["api"], example_server.service["worker"], example_server.service["scheduler"]. Remove "worker" — only example_server.service["worker"] is destroyed. The keys express business identity.

Pause and predict — reorder vs rename

Given for_each = toset(["api", "worker"]), change to toset(["worker", "api"]). Plan? Then change "api" to "backend". Plan?

Reordering: no instance identity change — sets are unordered. Renaming: destroy ["api"], create ["backend"], unless you add a moved block.

Chaining for_each preserves the keyspace

resource "example_network" "environment" {
  for_each = var.environments
  name     = each.key
}

resource "example_subnet" "application" {
  for_each = example_network.environment
  network_id = each.value.id
  name       = "${each.key}-application"
}

Both resource families share keys. Terraform understands that their instance identities change together.

Why graph knowledge improves design

When a plan contains too many unknowns, ask: Is a broad module-level depends_on delaying more work than necessary? Are instance keys derived from apply-time IDs? Is a data source querying something created in the same plan? When a graph contains cycles: create a base object first and configure the relationship later; pass existing IDs into a module; split bootstrapping from steady-state management.

Critical Concept

State — Terraform's Memory, Ownership Ledger, and Risk Concentration

State is often introduced as "a cache." That is incomplete. State is also: an identity map from resource address to provider object; a record of last-known attributes; provider configuration association metadata; dependency information needed for operations such as destroy; module output storage; lineage and serial metadata for safe updates.

Without state, Terraform cannot reliably know that example_server.api corresponds to provider object srv-91f2 in account production. Names are not sufficient — APIs allow duplicates, renames, generated IDs, and provider-specific composite identities.

State is not a database backup

State may record a database resource's ID, endpoint, engine version, configured size, and selected attributes. It does not contain the actual rows of the database merely because Terraform created the database service. Back up application data using the storage system's mechanisms. Back up Terraform state because losing it loses Terraform's ownership mappings.

Local state is a learning default, not a team architecture

By default, Terraform writes terraform.tfstate and terraform.tfstate.backup locally. Local state is useful for learning, disposable experiments, and isolated personal work with no valuable resources. It is weak for teams: one machine holds the authoritative copy; collaborators can plan from stale copies; simultaneous writers can conflict; laptop loss can lose ownership mappings.

Backends store state and may provide locking

terraform {
  backend "example" {
    # backend-specific, constant configuration
  }
}

Backend blocks cannot use input variables, locals, data sources, or resource attributes — Terraform must initialize the backend before it can evaluate the normal configuration. After a backend change, terraform init may ask whether to migrate existing state. Do not hardcode backend credentials.

Locking prevents two writers, not every race

sequenceDiagram
    participant A as Run A
    participant B as Backend lock
    participant C as Run B

    A->>B: acquire lock
    B-->>A: granted
    C->>B: acquire lock
    B-->>C: wait/fail
    A->>B: write new state
    A->>B: release lock
    B-->>C: lock available
    

Use a bounded wait in automation: terraform plan -lock-timeout=5m. Avoid -lock=false. State locking does not prevent: a human changing the cloud console; a different state managing the same object; an external system altering shared settings; API changes between plan and apply.

State contains secrets more often than people expect

If a value is supplied to or returned by a provider resource, it may be stored in state: generated passwords, private keys, connection strings, user-data scripts, tokens, database bootstrap values. Marking a variable or output sensitive = true redacts it from normal CLI/UI display — it does not remove it from state or saved plans.

MechanismHides normal displayOmitted from state/plan
sensitive = trueYesNo
ephemeral valueUse with sensitive if display also mattersYes
provider write-only argumentProvider-specific behaviorYes

Inspect through Terraform, do not hand-edit JSON

terraform state list
terraform state show 'example_server.api["blue"]'
terraform show
terraform output
terraform state pull     # contains raw state — treat as sensitive!

State boundaries are architecture boundaries

SignalSplit when...
OwnershipDifferent teams independently own lifecycle
PermissionsResources require materially different credentials
Blast radiusA failed plan should not affect the other component
Change cadenceComponents release on different schedules
LifecycleOne component outlives or is replaced independently
ScaleRefresh/plan time or state size is operationally painful

CLI workspaces: same configuration, different state instances

terraform workspace list
terraform workspace new dev
terraform workspace select prod

CLI workspaces give one working directory multiple state instances. They are not strong system decomposition — the same backend configuration is shared, switching is implicit, and permissions are harder to separate. For strongly separated production/dev environments, prefer explicit root configurations/backends calling shared modules.

Pause and predict — state loss

Terraform created ten objects. The state is permanently lost, but the objects remain. You still have the .tf files. Will terraform plan reconstruct the mappings automatically?

No. Configuration describes desired resource addresses and arguments, but it does not contain the provider IDs returned during the original creates. Terraform generally plans ten new objects. Recovery requires locating each real provider identity, confirming the correct address, importing each object, planning to reconcile configuration with real attributes, and resolving discrepancies.

Critical Concept

Changing Ownership Without Changing Infrastructure

Terraform must distinguish four intentions that can look similar in a Git diff:

IntentMechanism
Change objectEdit resource arguments and apply
Rename/move addressmoved block
Adopt existing objectimport block/command plus matching resource config
Stop managing, keep objectremoved block with destroy = false
Stop managing and delete objectRemove resource block and apply, or explicit destroy workflow

moved blocks preserve identity through refactoring

sequenceDiagram
    participant C as Configuration
    participant S as State
    participant T as Terraform plan

    C->>T: moved from old to new
    T->>S: is old address tracked?
    alt tracked
        T->>S: reinterpret association at new address
        T->>T: plan from existing object at new address
    else not tracked
        T->>T: continue — new address has no moved object
    end
    
moved {
  from = example_server.api
  to   = example_server.application
}

Safe plan: # example_server.api has moved to example_server.application. Suspicious plan: destroy followed by create. Always inspect the plan. Keep historical moved blocks in reusable modules — removing them breaks the upgrade path for consumers who skip intermediate versions.

Import is adoption, not discovery

resource "example_bucket" "audit" {
  name = "company-audit-prod"
}

import {
  to = example_bucket.audit
  id = "company-audit-prod"
}

Import does not mean Terraform found everything automatically, the existing object matches your configuration, or related child objects are automatically imported. After import, always review the plan — it may propose updates or replacement because configuration does not match live settings or provider defaults.

Import checklist
  1. Identify the exact provider account, region, and object ID.
  2. Read that resource type's provider import documentation.
  3. Write or generate candidate resource configuration.
  4. Add an import block targeting the intended address.
  5. Plan.
  6. Reconcile every diff deliberately.
  7. Apply.
  8. Verify a subsequent no-op plan.

removed blocks make abandonment reviewable

removed {
  from = example_server.legacy

  lifecycle {
    destroy = false
  }
}

This is safer than an unreviewed imperative state edit because the intent lives in configuration, appears in plan, and can be code-reviewed. Before abandoning an object, decide who owns it next, how it will be changed and deleted, and whether secrets or outputs still reference it.

State commands are useful fallbacks, not a default refactor language

terraform state mv OLD_ADDRESS NEW_ADDRESS
terraform state rm ADDRESS
terraform state replace-provider OLD_SOURCE NEW_SOURCE

Prefer configuration blocks when available because every workspace/consumer can apply the same transition and the history remains reviewable.

Sharp Tools

Lifecycle Rules — Sharp Tools with Precise Purposes

create_before_destroy

Normal replacement is destroy old → create new. With create_before_destroy = true, Terraform tries: create new → redirect dependencies → destroy old. Useful when duplicate objects may coexist and names can be unique during overlap. It can fail when the API enforces a unique name, capacity/quota cannot hold both, or the old object owns an exclusive attachment. This rule is not a universal zero-downtime switch.

prevent_destroy

Terraform rejects a plan that would destroy the resource while this rule remains in configuration. Good candidates: production databases, audit buckets, signing keys, foundational networks. Limits: removing the resource block also removes the rule from configuration; it does not protect against cloud-console deletion; it can intentionally block terraform destroy.

Pause and predict — deletion protection

A database has prevent_destroy = true. Someone deletes the entire resource block from configuration. Does the protection necessarily remain?

No. The rule disappeared with the block. Terraform can now plan destruction of the address still tracked in state. This is why organizational policy and provider-native deletion protection matter.

ignore_changes

This declares shared ownership: Terraform sets or observes an attribute but should ignore later differences for update planning. Legitimate uses: an autoscaler manages desired replica count; a policy engine writes one tag; a platform injects a value after creation. Risks: hides drift; leaves configuration claiming a value Terraform will not enforce; masks incidents or provider bugs.

replace_triggered_by

Force replacement based on another managed resource or attribute. Use when the downstream object cannot be updated safely after an upstream change, even though the provider does not naturally infer replacement.

-replace is an operational request

terraform plan -replace='example_server.api' -out=tfplan
terraform apply tfplan

Prefer -replace over terraform taint because the replacement request is visible in the plan operation rather than silently persisted in state ahead of review.

Architecture

Providers — The Executable Half of Terraform

flowchart TD
    REQ["required_providers\nsource + compatible versions"] --> LOCK[".terraform.lock.hcl\nselected provider version + checksums"]
    LOCK --> INSTALL["terraform init\ninstalls plugin"]
    CONFIG["provider block / environment\nendpoint + credentials + alias"] --> ASSOC["resource/provider association"]
    INSTALL --> ASSOC
    ASSOC --> API["API operations"]
    

Declare requirements

terraform {
  required_version = ">= 1.7.0, < 2.0.0"

  required_providers {
    example = {
      source  = "acme/example"
      version = "~> 3.2"
    }
  }
}

The source address identifies the provider globally. Every module declares its own provider requirements. Terraform selects one provider version compatible with the combined requirements for the whole configuration.

Commit the dependency lock file

terraform init writes .terraform.lock.hcl. Commit it for root configurations — it records the selected provider versions and checksums so local, CI, and remote runs install the same artifacts. Do not commit .terraform/.

Configure providers at the root

provider "example" {
  region = var.region
}

Reusable child modules should declare requirements but normally receive provider configurations from their caller. Credentials should come from the provider's standard external mechanisms: workload identity, short-lived federation, environment variables, credential files.

Aliases model multiple configurations

provider "aws" {
  region = "eu-west-2"
}

provider "aws" {
  alias  = "recovery"
  region = "eu-central-1"
}

resource "aws_s3_bucket" "replica" {
  provider = aws.recovery
  bucket   = "example-replica"
}

Provider upgrade discipline

Even with unchanged HCL, a new provider may normalize values differently, add computed fields, change defaults, fix a diff bug, mark an attribute replace-only, alter import identity parsing, or remove deprecated arguments. Upgrade process: read release notes, update the version constraint, run terraform init -upgrade, inspect lock file, format/validate/test, plan every representative state, separate provider upgrade from unrelated infrastructure changes.

Architecture

Modules — Boundaries of Meaning

Every Terraform configuration directory is a module. The directory where Terraform runs is the root module. A module called through a module block is a child module.

module "network" {
  source = "./modules/network"
  name       = "payments-prod"
  cidr_block = "10.42.0.0/16"
}

The child module's resources remain in the root run's state:

module.network.example_network.this
module.network.example_subnet.this["private-a"]

A module is not automatically a separate state, a separate deployment, a security boundary, or a microservice. It is a reusable configuration abstraction within a root configuration.

A good module raises the level of conversation

Stronger abstraction hides decisions callers should not make and exposes decisions they must:

module "audit_archive" {
  source  = "app.example.com/platform/audit-archive/example"
  version = "4.1.0"

  name              = "payments-prod"
  retention_years   = 7
  writer_identities = [module.application.runtime_identity]
}

Compose modules flatly

flowchart LR
    N["network module"] -->|"private subnet IDs"| D["database module"]
    N -->|"application subnet IDs"| A["application module"]
    D -->|"database address"| A
    

The root module connects explicit interfaces. Avoid deeply nested modules that secretly create all their dependencies. Flat composition improves reuse, dependency visibility, testing, swapping implementations, state refactoring, and policy review.

Module sources and versions

module "network" {
  source  = "app.example.com/platform/network/aws"
  version = "~> 5.3"
}

module "network" {
  source = "git::https://example.com/platform/network.git?ref=v5.3.2"
}

module "network" {
  source = "./modules/network"
}

Environments: share modules, separate roots

modules/
└── application/

live/
├── dev/
│   ├── main.tf
│   └── backend.tf
├── stage/
│   ├── main.tf
│   └── backend.tf
└── prod/
    ├── main.tf
    └── backend.tf

Cross-state dependencies

If application state needs a network ID from network state, options include: look up the network through the cloud provider using stable tags/IDs; publish the value in a configuration registry; consume explicitly exposed remote-state outputs; pass it through the deployment system. Avoid circular state dependencies — one boundary must own the shared relationship.

Pause and predict — moving into a module

Before: example_bucket.logs. After wrapping the unchanged resource in module "storage", the address becomes module.storage.example_bucket.logs. Will Terraform automatically infer the move because the arguments are identical?

No. You must record a moved block:

moved {
  from = example_bucket.logs
  to   = module.storage.example_bucket.logs
}

A module refactor is also an identity refactor.

Operations

The Safe Change Path — From Editor to Applied State

flowchart LR
    EDIT["edit"] --> FMT["fmt"]
    FMT --> VAL["validate"]
    VAL --> TEST["tests"]
    TEST --> PLAN["speculative plan"]
    PLAN --> REVIEW["human + policy review"]
    REVIEW --> MERGE["merge"]
    MERGE --> FINAL["fresh saved plan"]
    FINAL --> APPROVE["approval"]
    APPROVE --> APPLY["apply exact plan"]
    APPLY --> OBSERVE["post-apply checks"]
    

Layer 1 — formatting

terraform fmt -check -recursive

Layer 2 — static validation

terraform init -backend=false
terraform validate

Layer 3 — built-in configuration contracts

Variable validation rejects bad module input. Preconditions verify an assumption before acting. Postconditions verify a guarantee from a resource/data result — a failed postcondition can stop downstream work, but it cannot undo remote operations that already completed. Check blocks evaluate operational assertions; failed check assertions report warnings and do not block the Terraform operation.

Layer 4 — Terraform tests

// tests/manifests.tftest.hcl
run "development_plan" {
  command = plan

  variables {
    environment = "dev"
  }

  assert {
    condition     = output.retention_days == 14
    error_message = "Development retention must be 14 days."
  }
}
terraform test

Terraform tests can create real infrastructure and therefore cost money. Use isolated credentials/accounts, unique naming, quotas, timeouts, cleanup, and provider mocking where appropriate.

Layer 5 — plan review

For pull requests, produce a speculative plan. After merge, produce a fresh saved plan:

terraform plan -out=tfplan
terraform show tfplan
terraform apply tfplan

# In automation:
terraform plan -detailed-exitcode
# 0 = success, empty diff | 1 = error | 2 = success, non-empty diff

A destructive-change review

For every replacement or destruction, answer: What exact address is affected? Which remote object/account/region? Why did Terraform choose replacement? Is stateful data attached? Does deletion protection exist? Can old and new coexist? What depends on the object? What is the outage window? Is rollback possible? "Only one resource" is not a risk assessment.

Policy and organizational guardrails

Policy can reject plans that violate rules such as public storage, unrestricted ingress, missing encryption, disallowed regions, destruction of protected resource classes. Keep policies versioned, tested, understandable, and paired with actionable messages.

Credentials for automation

Prefer: CI identity → short-lived federation → scoped provider credentials. Avoid: long-lived admin access key → repository secret → every Terraform job.

Secrets in configuration

Mark variables sensitive = true. Use ephemeral = true and write-only arguments where supported. Best architecture often lets Terraform grant an application identity permission to read a secret at runtime rather than making Terraform carry the secret value itself.

Provisioners are a last resort

Provisioners weaken Terraform's model because Terraform usually cannot plan the script's effects, detect drift, or roll the action back. Prefer purpose-built provider resources, machine images, cloud-init/user data, configuration management, or application deployment systems.

Troubleshooting

Failure Clinic — Diagnose from the Layer That Failed

flowchart TD
    F["Terraform failed or surprised you"] --> L1{"Initialization?"}
    L1 -->|Yes| I["backend/module/provider install\nversions, network, auth, lock file"]
    L1 -->|No| L2{"Configuration evaluation?"}
    L2 -->|Yes| C["syntax, types, references,\nunknown keys, cycles"]
    L2 -->|No| L3{"State/refresh?"}
    L3 -->|Yes| S["workspace, backend, lock,\nprovider account/region, drift"]
    L3 -->|No| L4{"Provider plan?"}
    L4 -->|Yes| P["schema, defaults, replacement,\nprovider version/bug"]
    L4 -->|No| L5{"Apply/API?"}
    L5 -->|Yes| A["credentials, quota, API error,\neventual consistency, partial success"]
    L5 -->|No| O["postconditions, checks,\napplication/system behavior"]
    

Failure: state lock cannot be acquired

Read lock owner, operation, time, and lock ID. Check the remote run queue/CI system. Contact the owner. Wait using -lock-timeout. Prove the original operation ended. Only then consider force-unlock. Never use -lock=false.

Failure: cycle detected

Example: security group rule needs load balancer ID; load balancer needs security group ID. Fix the model: create the security group without the rule, create the load balancer, create a separate rule resource referencing both. Do not add more depends_on — a dependency edge cannot solve a circular dependency.

Failure: invalid for_each argument

Keep identity keys configuration-known. Unknown values may appear inside each instance.

Failure: inconsistent final plan/result

Often indicates provider bug, unstable API normalization, or remote eventual consistency. Record Terraform version, provider version and checksums, smallest configuration, redacted plan/log output, state backup, and API evidence.

Failure: wrong account or region

Symptoms: all objects appear missing, plan proposes mass creation, refresh-only proposes removing many objects from state. Stop. Verify selected backend/workspace, provider alias, account identity, region/project, credential source, and environment variables.

Failure: apply stopped halfway

sequenceDiagram
    participant T as Terraform
    participant A as API object A
    participant B as API object B
    participant S as State

    T->>A: create
    A-->>T: success, id A-1
    T->>S: record successful result
    T->>B: create
    B-->>T: quota error
    T-->>T: apply fails
    

Recovery: preserve the full error output; do not immediately rerun with random changes; confirm the state write succeeded; inspect terraform state list/show; inspect remote reality through provider tooling; fix the root cause; run a fresh normal plan; review what Terraform now proposes; apply the remaining convergence. Never assume an error means "nothing changed."

Failure: manual change keeps returning

This is ownership conflict. Choose: Terraform owns it (stop the other writer); the other system owns it (remove/ignore the argument narrowly); split the resource/attachment; or redesign the interface. Repeated drift is often a governance problem, not a Terraform bug.

Failure: prevent_destroy blocks legitimate migration

Sequence: understand why replacement/destruction is planned; take/verify backups; enable provider-native deletion protection; design migration and rollback; narrow the code change that temporarily changes the lifecycle guard; review the exact saved plan; apply with approval; restore protection on the replacement; verify.

A diagnostic command ladder

terraform version
terraform workspace show
terraform providers
terraform state list
terraform state show 'ADDRESS'
terraform plan
terraform show tfplan
terraform graph
Checkpoint

The Second Checkpoint — Explain the Dangerous Parts

Test yourself
  1. Why is deleting a resource block an infrastructure instruction?
  2. How does a moved block differ from terraform state mv?
  3. What must be reconciled after importing an object?
  4. Why is ignore_changes a declaration of shared ownership?
  5. Why can create_before_destroy still produce downtime or fail?
  6. What is stored in .terraform.lock.hcl, and why commit it?
  7. Why should reusable child modules not configure provider credentials?
  8. Why is a child module not a separate state?
  9. Why is an apply failure not evidence that nothing changed?
  10. What is the difference between a speculative plan and a final saved plan?
Hands-On

Field Workshop — Learn Terraform Without a Cloud Bill

This workshop creates names and local JSON files using the random and local providers — exercising real provider installation, resource identity, state, drift, imports, tests, refactoring, and destruction. Estimated time: 45–75 minutes.

flowchart LR
    INPUT["set of environments\ndev · stage"] --> PET["random_pet instances\nstable generated names"]
    PET --> FILE["local_file instances\none JSON manifest per key"]
    FILE --> OUT["outputs\npaths + codenames"]
    PET --> STATE[("Terraform state")]
    FILE --> STATE
    

What you will prove: init resolves and locks provider versions; unknown values can flow through a graph whose keys are already known; for_each keys create stable addresses; a no-op plan represents convergence; drift is detected through provider reads; state records generated identities; a rename without moved means replacement; a rename with moved preserves objects; import establishes ownership; a removed block relinquishes ownership; terraform test can validate the module interface.

Prerequisites

Install Terraform CLI from HashiCorp's official installation instructions (1.7 or later). Optional tools: jq, git. The core lab needs network access during terraform init to download two providers but uses no cloud credentials.

Build the project

terraform-understanding-lab/
├── .gitignore
├── main.tf
├── outputs.tf
├── variables.tf
├── versions.tf
└── tests/
    └── manifests.tftest.hcl

.gitignore

.terraform/
*.tfstate
*.tfstate.*
*.tfplan
crash.log
crash.*.log
generated/
override.tf
override.tf.json
*_override.tf
*_override.tf.json

Notice what is absent: .terraform.lock.hcl — commit the lock file for a root configuration.

versions.tf

terraform {
  required_version = ">= 1.7.0, < 2.0.0"

  required_providers {
    local = {
      source  = "hashicorp/local"
      version = "~> 2.9"
    }

    random = {
      source  = "hashicorp/random"
      version = "~> 3.9"
    }
  }
}

variables.tf

variable "environments" {
  type        = set(string)
  description = "Stable environment keys used to create manifest instances."
  default     = ["dev", "stage"]

  validation {
    condition = alltrue([
      for environment in var.environments :
      can(regex("^[a-z][a-z0-9-]*$", environment))
    ])
    error_message = "Each environment must use lowercase letters, numbers, or hyphens and start with a letter."
  }
}

variable "owner" {
  type        = string
  description = "Owner recorded in every generated manifest."
  default     = "platform-learning"

  validation {
    condition     = length(trimspace(var.owner)) > 0
    error_message = "owner must not be empty."
  }
}

main.tf

locals {
  metadata = {
    managed_by = "terraform"
    owner      = var.owner
  }
}

resource "random_pet" "environment" {
  for_each = var.environments

  prefix    = each.key
  length    = 2
  separator = "-"
}

resource "local_file" "manifest" {
  for_each = random_pet.environment

  filename             = "${path.module}/generated/${each.key}.json"
  file_permission      = "0644"
  directory_permission = "0755"

  content = jsonencode({
    codename    = each.value.id
    environment = each.key
    metadata    = local.metadata
  })

  lifecycle {
    precondition {
      condition     = contains(var.environments, each.key)
      error_message = "Every manifest key must come from the environments input."
    }
  }
}

check "one_manifest_per_environment" {
  assert {
    condition     = length(local_file.manifest) == length(var.environments)
    error_message = "Every environment must have exactly one manifest."
  }
}

outputs.tf

output "manifest_paths" {
  description = "Generated manifest path for each environment."
  value = {
    for environment, manifest in local_file.manifest :
    environment => manifest.filename
  }
}

output "codenames" {
  description = "Stable generated codename for each environment."
  value = {
    for environment, pet in random_pet.environment :
    environment => pet.id
  }
}

tests/manifests.tftest.hcl

run "default_environments_have_manifests" {
  command = plan

  assert {
    condition = toset(keys(output.manifest_paths)) == toset([
      "dev",
      "stage",
    ])
    error_message = "Default manifest keys must be dev and stage."
  }

  assert {
    condition = alltrue([
      for path in values(output.manifest_paths) :
      endswith(path, ".json")
    ])
    error_message = "Every manifest path must end in .json."
  }
}

run "custom_environment_changes_keyspace" {
  command = plan

  variables {
    environments = ["prod"]
    owner        = "release-engineering"
  }

  assert {
    condition     = toset(keys(output.manifest_paths)) == toset(["prod"])
    error_message = "Custom environments must define the resource keyspace."
  }
}

Act A — initialize and inspect dependency selection

terraform init
terraform providers
git status --short
sed -n '1,220p' .terraform.lock.hcl
Pause and predict

If a teammate clones the project with the lock file and runs terraform init, will Terraform simply choose the newest provider versions matching ~>?

Normally it reuses the locked selections if they still satisfy the constraints. terraform init -upgrade deliberately asks Terraform to reconsider newer allowed versions.

Act B — format, validate, and test

terraform fmt -recursive
terraform validate
terraform test

Introduce an invalid environment temporarily ("Production!") to see input validation stop planning with your actionable message. Then restore ["dev", "stage"].

Act C — plan before creating

terraform plan -out=first.tfplan
terraform show first.tfplan

Predict the addresses: random_pet.environment["dev"], random_pet.environment["stage"], local_file.manifest["dev"], local_file.manifest["stage"]. The keys and filenames are known; the generated codenames and therefore final file content are not.

Act D — apply the exact plan

terraform apply first.tfplan
terraform output
terraform state list
terraform state show 'random_pet.environment["dev"]'

Act E — expand a keyspace

Change default = ["dev", "stage", "prod"].

Pause and predict

How many new instances?

Two: + random_pet.environment["prod"] and + local_file.manifest["prod"]. Existing dev and stage addresses remain. Reorder to ["prod", "dev", "stage"] — because the variable is a set, order is not identity. Expect no changes.

Act F — remove one stable key

Change default = ["dev", "prod"]. Only ["stage"] is destroyed. Nothing turns prod into stage. This is the practical difference between stable for_each keys and positional count indexes.

Act G — create and repair drift

printf '{"edited":"outside terraform"}\n' > generated/dev.json
terraform plan
terraform apply

The local provider reads the actual file and detects that it no longer matches the managed content. Terraform restores the desired content. Delete a generated file and plan — the file remains declared at a tracked address, so Terraform proposes creating it again. This is drift correction when Terraform runs. No background Terraform process recreated the file at deletion time.

Act H — observe the danger of a rename, then fix it

Rename the resource block to random_pet "codename" (without a moved block). Plan shows destroy for random_pet.environment["dev"] and create for random_pet.codename["dev"]. Do not apply. Add:

moved {
  from = random_pet.environment
  to   = random_pet.codename
}

The existing generated identities move to the new addresses instead of being recreated. This is the opening puzzle resolved with real state.

Act I — import an existing identity

resource "random_uuid" "adopted" {}

import {
  to = random_uuid.adopted
  id = "d9428888-122b-4f20-8f16-ff5e7c0b6ce0"
}

Plan and apply. The import block did not generate the UUID — it declared that this existing identity belongs at that address.

Act J — relinquish ownership without destroy

removed {
  from = random_uuid.adopted

  lifecycle {
    destroy = false
  }
}

Terraform proposes forgetting the address without destroying an underlying object. random_uuid.adopted is no longer tracked.

Act K — examine state carefully

terraform state list
terraform state show 'random_pet.codename["dev"]'
terraform state pull     # raw state — treat as sensitive!

Act L — rerun tests after refactoring

terraform fmt -recursive
terraform validate
terraform test
terraform plan

Tests still pass because they assert the public outputs rather than the old internal resource label.

Act M — destroy deliberately

terraform plan -destroy
terraform destroy
terraform state list
find generated -type f -maxdepth 1 -print

Remove disposable local artifacts only after confirming destruction.

Workshop debrief

EventWhy Terraform did it
First plan created four instancesTwo known environment keys across two resource blocks
Codenames were unknownProvider generates them during create
Second plan was emptyConfiguration, state, and files agreed
Adding prod left dev/stage alonefor_each keys preserve identity
Reordering did nothingSet membership, not position, was identity
Manual edit caused a diffProvider refresh observed file drift
Rename planned replacementsResource address changed
moved prevented replacementState associations were relocated before planning
Import tracked a fixed UUIDExisting identity was associated with an address
removed forgot the UUIDState ownership ended without destroy
Tests survived internal renameThey asserted output contract
Reference

Operator's Pocket Guide

Everyday loop

terraform fmt -recursive
terraform validate
terraform test
terraform plan
terraform plan -out=tfplan
terraform show tfplan
terraform apply tfplan

Initialize and upgrade

terraform init
terraform init -backend=false
terraform init -upgrade
terraform providers
terraform modules

Inspect

terraform version
terraform workspace show
terraform state list
terraform state show 'ADDRESS'
terraform output
terraform show
terraform show tfplan
terraform graph

Special planning modes

terraform plan -destroy
terraform plan -refresh-only
terraform plan -replace='ADDRESS'
terraform plan -detailed-exitcode

State/refactor operations — use deliberately

terraform state mv 'OLD' 'NEW'
terraform state rm 'ADDRESS'
terraform import 'ADDRESS' 'PROVIDER_ID'
terraform force-unlock 'LOCK_ID'

Prefer moved, import, and removed configuration blocks when suitable.

Cleanup

terraform plan -destroy
terraform destroy
Reference

Plan-Review Decision Tree

flowchart TD
    P["Read the plan"] --> W{"Correct backend,\nworkspace, account, region?"}
    W -->|No / unsure| STOP["STOP"]
    W -->|Yes| D{"Any destroy or replacement?"}
    D -->|Yes| ID{"Expected identity change?"}
    ID -->|No| MOVE["Check address rename,\nfor_each keys, imports, moved blocks"]
    ID -->|Yes| DATA{"Stateful or shared object?"}
    DATA -->|Yes| MIG["Require backup, migration,\ndowntime and rollback review"]
    DATA -->|No| DOWN["Check dependency impact\nand availability strategy"]
    D -->|No| U{"In-place updates safe?"}
    U -->|No / unclear| DOC["Read provider docs/changelog\nand system impact"]
    U -->|Yes| K{"Unknown values or deferred reads?"}
    K -->|Yes| TRACE["Trace graph and apply-time risk"]
    K -->|No| OUT{"Outputs/policies/cost acceptable?"}
    TRACE --> OUT
    MIG --> OUT
    DOWN --> OUT
    DOC --> OUT
    OUT -->|No| STOP
    OUT -->|Yes| FRESH{"Final saved plan fresh\nand approved?"}
    FRESH -->|No| STOP
    FRESH -->|Yes| APPLY["Apply exact plan\nthen verify"]
    
Reference

Misconceptions to Remove Permanently

MisconceptionReality
"Terraform compares .tf files with the cloud"It also needs state to know which provider object belongs to which address.
"The resource label is just a variable name"It is part of durable identity.
"Plan is dry-run apply"It is a provider-informed proposal based on current observations. APIs can still behave differently during apply.
"Sensitive means not stored"Sensitive normally means redacted from display. Use ephemeral/write-only to avoid persistence.
"A module has its own state"Child module resources share the root run's state unless called by a separate root configuration.
"Workspaces are environments"CLI workspaces are multiple state instances for one directory. They are not automatically strong credential, backend, or access boundaries.
"depends_on fixes ordering"It models a dependency. It cannot fix cycles, API readiness, or application retries.
"create_before_destroy guarantees zero downtime"It changes replacement order where possible. The system still needs coexistence, health checks, and traffic shifting.
"State is safe because it is just metadata"State can contain credentials, private keys, scripts, and passwords.
"An apply error means Terraform rolled back"Terraform is not a cross-provider transaction manager. Successful earlier actions may remain and be recorded.
"-target makes large Terraform faster"Routine targeting hides parts of the configuration. Split state along architectural boundaries instead.
"If an object already exists, Terraform will find it"Existing objects require explicit data lookup or import. Terraform does not adopt by visual similarity.
Synthesis

Final Mental Model

flowchart TB
    subgraph CODE["CODE"]
        HCL["HCL blocks + expressions"]
        ADDR["stable resource addresses"]
        MOD["module contracts"]
        HCL --> ADDR --> MOD
    end

    subgraph PREP["INITIALIZATION"]
        BACK["backend"]
        DEPS["module/provider packages"]
        LOCK["dependency lock file"]
    end

    subgraph THINK["PLAN"]
        STATE["prior state identities"]
        READ["provider refresh"]
        GRAPH["dependency graph + unknown values"]
        RULES["provider schema + lifecycle"]
        PROPOSAL["reviewable action proposal"]
        STATE --> READ --> GRAPH
        RULES --> GRAPH --> PROPOSAL
    end

    subgraph ACT["APPLY"]
        CALLS["ordered/parallel API calls"]
        RESULTS["provider IDs + final attributes"]
        NEWSTATE["new durable state"]
        CALLS --> RESULTS --> NEWSTATE
    end

    CODE --> THINK
    PREP --> THINK
    THINK --> ACT
    

Terraform is a graph planner and stateful ownership system:

  1. Configuration declares resource instances and relationships.
  2. Addresses give those instances durable Terraform identity.
  3. State connects addresses to provider object identities.
  4. Providers refresh reality and define API-specific change semantics.
  5. Terraform builds a plan with known and unknown values.
  6. Humans and policy review the proposed transition.
  7. Apply walks the graph, calls APIs, and records successful results.
  8. Terraform exits; later runs observe subsequent drift.
Synthesis

Final Exam — Reason from the Machine

Do not answer with slogans. Narrate configuration, state, reality, provider, address, graph, and plan.

Final exam (narrate, don't recite)
  1. A block label changes but every remote argument stays identical. Why might the plan replace the object, and how do you prevent it?
  2. A resource exists in the cloud with the desired name but no state association. Why does Terraform plan another one?
  3. Why can for_each = toset(resource.example[*].id) fail before apply?
  4. A remote autoscaler changes replicas from 3 to 8. Compare normal and refresh-only plans.
  5. A database password is marked sensitive. Where can it still appear?
  6. An apply creates a network and then fails creating a subnet. What should the next operator inspect before retrying?
  7. A team needs separate production credentials and approvals. Why might CLI workspaces be insufficient?
  8. A child module receives an aliased provider. Where should the provider requirement and provider configuration live?
  9. One resource is replaced during a provider upgrade despite no HCL change. How can this be legitimate, and how should it be reviewed?
  10. A resource should remain in the cloud but move to another management system. What transition expresses that intent?
  11. Why can a postcondition stop downstream actions but not roll back earlier API calls?
  12. When is ignore_changes appropriate, and what ownership document should accompany it?
  13. What does a state lock protect, and what important races remain?
  14. Why is applying a saved plan stronger than approving a speculative PR plan and later running plain terraform apply?
  15. How would you choose whether network and application resources belong in the same state?
If you can answer all fifteen from the three-world model, you are ready to operate Terraform deliberately rather than experimentally.
Reference

Official Source Trail

The concepts and current language behavior in this guide were checked against HashiCorp's official documentation: