What terraform init Actually Does to Your Directory
You have a folder of .tf files and a freshly installed binary. Before anything else works, terraform init runs — once per working directory, and again whenever the configuration's dependencies or backend change. It is the only command that mutates the working directory's own machinery; every other command treats that machinery as read-only input.
init performs four jobs, in order. First, it resolves every provider the configuration references and downloads the pinned versions into .terraform/providers/, then writes .terraform.lock.hcl — the dependency lockfile recording exact provider versions and their hashes. Commit that lockfile to git. It is the provider equivalent of a lockfile in a Node project: it makes every machine and CI runner use bit-identical provider binaries. The parent .terraform/ directory, by contrast, is a local cache and must be gitignored.
terraform init
# Terraform has been successfully initialized!
Second, it configures the backend — where state lives. By default that is the local backend: state written to terraform.tfstate in the working directory. In real teams you point it at remote shared storage instead, declared in the same terraform block:
terraform {
backend "s3" {
bucket = "acme-tfstate-prod"
key = "networking/terraform.tfstate"
region = "eu-west-1"
dynamodb_table = "tf-locks" # state locking
encrypt = true
}
}
The backend stanza's arguments are intentionally not filled in with expressions — backends are configured before any state exists to evaluate them, so values come from init -backend-config=... flags or files. Changing the backend definition requires terraform init -reconfigure (switch without migrating state) or -migrate-state (copy existing state into the new backend). This distinction prevents the worst possible accident: silently pointing production state at the wrong bucket.
Third, init installs any child modules declared with source arguments, downloading them under .terraform/modules/. Fourth, it validates enough of the configuration to fail early on broken syntax or undeclared providers. What init deliberately does not do: create anything. There is no cloud API call that costs money.
Two flags pay for themselves immediately. terraform init -upgrade re-resolves providers, allowing newer versions within your ~> constraints — the normal way to pick up fixes — and terraform init -reconfigure re-runs backend setup while explicitly refusing to migrate existing state. Run init again with no changes and it reports "Terraform is up-to-date!" in milliseconds, so re-running is free.
Read init's output as a manifest of what changed in your directory's machinery:
Initializing the backend...
Initializing provider locks...
- Installing hashicorp/local v2.5.2...
- Using hashicorp/local v2.5.2 from the shared cache
Terraform has been successfully initialized!
The lockfile entry Terraform wrote records the provider version, the upstream registry protocol it came from, and cryptographic hashes (h1: and zh:) of the exact binary. On every later init, those hashes are enforced: even if the registry were compromised and served a different binary for the same version number, init fails with a checksum mismatch instead of running it. That is the mechanism behind the earlier advice to commit .terraform.lock.hcl. For environments with no internet access, -plugin-dir=... skips the registry entirely and installs from a local directory of provider binaries, which is how mirrored or air-gapped pipelines stay deterministic.
Your directory is now armed; the next command turns configuration into a prediction.
Reading a Plan Like a Contract, Not a Suggestion
terraform plan is where the declarative promise becomes visible. It refreshes state from the provider APIs, evaluates your configuration against that state, and prints exactly what an apply would do — creating, updating, or destroying objects — without touching anything. The summary line is the fastest read in the whole tool:
Terraform will perform the following actions:
# aws_s3_bucket.logs will be created
+ resource "aws_s3_bucket" "logs" {
+ bucket = "acme-logs-20261009"
+ id = (known after apply)
}
# aws_s3_bucket_versioning.logs will be updated in-place
~ resource "aws_s3_bucket_versioning" "logs" {
~ versioning_configuration {
~ status = "Suspended" -> "Enabled"
}
}
Plan: 1 to add, 1 to change, 0 to destroy.
Learn to read the markers: + is a create, ~ an in-place update, - a destroy, and -/+ (shown as "must be replaced") a destroy followed by create — the operation that breaks stateful resources. (known after apply) flags values the provider cannot compute until the object exists, such as generated IDs. A plan ending "No changes. Your infrastructure matches the configuration" is the health check you want before every merge.
Four habits make plans trustworthy. Refresh is not optional — each plan re-reads reality, so console edits show up as pending changes rather than surprises. Never apply straight from a bare terraform apply in shared environments; a bare apply runs an implicit plan that nobody reviewed. Save the plan instead with -out:
terraform plan -out=tf.plan # binary artifact: reviewed, reviewable, replayable
terraform show -json tf.plan # machine-readable form for policy checks (e.g. OPA/Sentinel)
The saved file is the exact operation apply will perform — an apply of a plan file never re-plans, so what reviewers approved is what runs. Note the plan file contains every value in cleartext, secrets included, so treat it like a credential. Third, pass inputs explicitly: variables arrive at plan time via -var, -var-file=prod.tfvars, or TF_VAR_ environment variables, and the same inputs must be supplied at apply — a plan made with dev variables and an apply made with prod variables produces two different worlds, which is why automation bundles both from the same source. Fourth, check the diff, not just the count — "1 to change" can be anything from a tag edit to a database engine version, and only the per-resource diff distinguishes them.
For CI specifically, two options beat grepping plan output. terraform plan -detailed-exitcode returns exit code 0 for "no changes", 1 for error, and 2 for "changes present" — letting a pipeline branch on drift detection without parsing text. And terraform plan -json streams machine-readable change objects (the same Cloud Event format HCP Terraform consumes), which policy engines such as OPA or Checkov evaluate to block, for example, a security group opened to 0.0.0.0/0 before any human sees the pull request.
An engineer who can look at a plan output and narrate, before pressing anything, what will exist afterward that does not exist now — and what will be gone — has understood the whole model.
Applying: Confirmation, Artifacts, and Locking Under Concurrency
terraform apply executes a plan. In its most common form you type it bare, Terraform runs an implicit fresh plan, shows you the diff, and asks the oldest question in the tool:
Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.
Enter a value:
Only the literal string yes confirms; anything else aborts. That gate exists because apply spends real money and destroys real objects. In automation there is no one to type yes, so CI passes -auto-approve — appropriate only after the pipeline has already produced and reviewed a plan, never as a way to skip review locally. The safer automation pattern saves the plan first and applies the artifact, which skips re-planning entirely:
terraform plan -out=tf.plan
terraform apply tf.plan # executes exactly this plan, no new diff
The critical rule behind the mechanics: an apply always executes the plan it was given, even if the world changed in between. That is a feature for correctness of review and a hazard if the plan sat around for a day — the saved artifact's assumptions may be stale, which is why CI should save, review, and apply within one pipeline run rather than archiving plans for later use.
During apply, Terraform takes a state lock so two concurrent applies cannot interleave writes and corrupt the state file. With a remote backend the lock lives in the backend (for S3, a DynamoDB item or the backend's native lock); the local backend locks terraform.tfstate itself. If a run crashes — laptop closed, terminal killed, CI job cancelled — the lock can outlive the process, and the next apply fails with "Error acquiring the state lock". The recovery path: terraform show the lock's info to see who holds it, and if that process is verifiably dead, terraform force-unlock <LOCK_ID>. Never reach for force-unlock while a colleague's apply might genuinely be running; that is how two writers corrupt state.
Automation also needs the non-interactive forms of the guard rails: -input=false makes any missing-variable prompt a hard error instead of a hung pipeline, -lock-timeout=30s waits for a concurrent run's lock to free rather than failing instantly, and -replace=<addr> forces a specific object to be rebuilt — the standard remedy for a wedged instance that a normal plan would leave in place because its attributes still match. These flags belong in the pipeline definition, not in anyone's muscle memory, so that every apply in a shared environment is reproducible from the code that triggered it.
After a successful apply you get two artifacts: changed infrastructure in your cloud account, and updated state recording what now exists. Failures mid-apply leave both partially updated — resources created before the failure are still recorded in state, so the next plan shows the true remaining delta. That self-healing property, rather than transactional rollback, is how Terraform copes with partial failure. You will now put the full cycle to work in Deploying Your First Resource.
Quiz
1. You initialize a directory containing a `backend "s3"` block. What must you supply the backend arguments with, and why?
2. A plan output reads `Plan: 0 to add, 1 to change, 1 to destroy.` A colleague says "no new resources, so the change is safe." What is wrong with that reading?
3. CI runs `terraform plan` on Monday, saves the artifact, and applies it Friday. What guarantee does the apply give?
4. An apply fails with "Error acquiring the state lock" after a colleague's CI job timed out. What is the correct response?
Flashcards
Question
What four things does terraform init do, and which of its outputs belong in git?
Click to reveal answer
Answer
It downloads pinned provider binaries into .terraform/providers/, writes .terraform.lock.hcl, installs child modules under .terraform/modules/, and configures the backend. Commit .terraform.lock.hcl; gitignore .terraform/, terraform.tfstate, and crash logs. Backends are configured from init-time literal values, not expressions.
Question
What do the plan diff markers +, ~, -, -/+, and (known after apply) mean?
Click to reveal answer
Answer
`+` create, `~` in-place update, `-` destroy, `-/+` destroy-then-replace (with a preceding "must be replaced" note), and `(known after apply)` for attributes the provider can only compute once the object exists — all read against the "Plan: X to add, Y to change, Z to destroy" summary.
Question
Why does applying a saved plan file (terraform apply tf.plan) beat a bare terraform apply?
Click to reveal answer
Answer
A bare apply runs an implicit plan that nobody reviewed and may differ run to run; applying the -out artifact executes exactly the reviewed operations with no re-planning, so what the reviewer saw is what runs. The artifact contains secrets in cleartext, so it must be handled like a credential.
Question
What is state locking, who enforces it, and what is the sanctioned recovery from a stale lock?
Click to reveal answer
Answer
Apply and destroy take an exclusive lock on state so concurrent runs cannot interleave writes; remote backends enforce it (S3 via DynamoDB or native lock files), the local backend locks terraform.tfstate. If the locking process is verifiably dead, `terraform force-unlock <LOCK_ID>` clears it — never while a real run may be active.
Question
What does an apply leave behind when it fails halfway through creating resources?
Click to reveal answer
Answer
All objects created before the failure exist in the cloud and remain recorded in state; nothing is rolled back. The next plan re-reads reality and shows the remaining delta to converge — Terraform recovers by re-converging, not by transactional rollback.
Revision Notes
Key Takeaways
- 1. init is the only command that mutates working-directory machinery: providers under .terraform/, hash-locked in .terraform.lock.hcl (commit it), modules, and backend configuration from literal init-time values
- 2. Plans refresh reality first, so drift appears as pending changes; read the summary counts and the `+`/`~`/`-`/`-/+` markers, not just the headline
- 3. A saved plan is the contract: apply executes it verbatim without re-planning, which is why pipelines must produce, review, and apply in one run
- 4. Applies hold a state lock; a crashed runner leaves a stale lock whose ID is in the error message, and force-unlock is the deliberate recovery
- 5. Partial failure leaves created objects in state and reality; the next plan converges from there — Terraform heals by re-planning, not rolling back
Interview Tips
- • "Walk me through your Terraform workflow." — init (deps + backend), plan to a saved artifact, human review of the diff, apply of that artifact; mention locking and that apply never re-plans a saved file
- • "What is in .terraform.lock.hcl and should it be committed?" — exact provider versions and hashes; yes, commit it, because it guarantees every machine runs identical provider binaries — contrast with .terraform/ which is a cache
- • "How does Terraform avoid two engineers corrupting state?" — backend-enforced locking around apply/destroy; explain the stale-lock failure mode and the force-unlock procedure gated on verifying the holder is gone
- • "Plan says 1 to add, 1 to change, 1 to destroy — safe?" — no; counts are not risk. Only the per-resource diff reveals whether the destroy is a database or a scratch resource, and whether the change is an update or a replacement
Cheat Sheet
init → plan → apply
init (directory setup only, no cloud writes):
terraform init # providers + lockfile + modules + backend
terraform init -upgrade # allow newer providers within constraints
terraform init -reconfigure # switch backend without migrating state
terraform init -migrate-state # carry state into the new backend
Commit .terraform.lock.hcl; gitignore .terraform/, terraform.tfstate*.
plan (read-only, refreshes first):
terraform plan -out=tf.plan # saved artifact = reviewed, replayable operations
terraform plan -destroy # preview full teardown
terraform show -json tf.plan # machine-readable for policy checks
Markers: + add · ~ update · - destroy · -/+ replace · (known after apply) deferred value. Plan files contain secrets in cleartext.
apply:
terraform apply tf.plan # executes exactly that plan
terraform apply -auto-approve # CI only, after a reviewed plan step
Prompts for literal yes in interactive mode; takes the state lock for the duration.
Stuck lock: read the holder from the error → verify it's dead → terraform force-unlock <LOCK_ID>. Never -lock=false, never delete state.