The Terraform Workflow
The idea in one sentence
Section titled “The idea in one sentence”Four commands cover almost everything you do day to day: terraform init sets up the working directory, terraform plan shows you a dry-run diff, terraform apply executes it, and terraform destroy tears down what the configuration manages.
init, plan, apply
Section titled “init, plan, apply”terraform init is the command you run first, in any directory, and after adding a new provider or module. It reads the required_providers block, downloads the matching provider plugins, and initializes the backend that stores state.
terraform initterraform plan is a dry run. It reads real AWS state, compares it against your configuration, and prints the diff — resources to create, update in place, or destroy — without changing anything.
terraform plan# example outputTerraform will perform the following actions:
# aws_s3_bucket.reports will be created + resource "aws_s3_bucket" "reports" { + bucket = "acme-monthly-reports" + id = (known after apply) }
Plan: 1 to add, 0 to change, 0 to destroy.terraform apply executes a plan: it recomputes the diff (unless you hand it a saved plan file, covered below), shows it to you, asks for confirmation, and then actually calls the AWS API to create, update, or destroy resources.
terraform applyterraform destroy is the inverse of apply: it computes a plan to remove every resource this configuration manages, and — after confirmation — deletes them.
terraform destroyflowchart LR
init["terraform init"] --> plan["terraform plan"]
plan --> review{"Review the diff"}
review --> apply["terraform apply"]
apply --> real["Real infrastructure updated"]
init -.-> destroy["terraform destroy"]
destroy -.-> gone["Managed resources removed"] Saving a plan before applying it
Section titled “Saving a plan before applying it”Plain terraform apply recomputes its own plan right before running it. Between the moment you reviewed a plan output and the moment someone runs apply, the real world can change — someone else applies a conflicting change, an AWS quota shifts, anything. The plan apply recomputes at that point might not be the one you reviewed.
Saving the plan to a file closes that gap: apply is then told to execute exactly that file, with no recomputation.
terraform plan -out=tfplanterraform apply tfplanThis pattern matters most in CI/CD: a pipeline can run plan -out=tfplan, post the diff for human review, and only run apply tfplan after approval — guaranteeing the applied changes are precisely the ones a human signed off on.
Real end-to-end proof, with a provider-agnostic local_file resource (the mechanism is identical for aws_s3_bucket; the AWS API calls at apply time just aren’t reachable offline):
$ terraform plan -out=tfplanPlan: 1 to add, 0 to change, 0 to destroy.
Saved the plan to: tfplan
To perform exactly these actions, run the following command to apply: terraform apply "tfplan"
$ terraform apply tfplanlocal_file.config: Creating...local_file.config: Creation complete after 0s [id=5a6df720540c20d95d530d3fd6885511223d5d20]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.apply tfplan runs with no confirmation prompt at all — proof that it isn’t recomputing and re-asking, it is executing the exact plan already reviewed and saved.
-detailed-exitcode: telling “no changes” from “changes pending” by exit code alone
Section titled “-detailed-exitcode: telling “no changes” from “changes pending” by exit code alone”By default, terraform plan’s exit code is 0 whether or not there are changes — useless for a CI script deciding whether to open a pull request or send a notification. -detailed-exitcode fixes that with three distinct, documented exit codes: 0 = succeeded, no changes; 1 = error; 2 = succeeded, changes present.
Real proof, same directory, two states:
$ terraform plan -out=tfplan -detailed-exitcodePlan: 1 to add, 0 to change, 1 to destroy.Saved the plan to: tfplan$ echo $?2
$ terraform apply tfplan # (applies the change)
$ terraform plan -detailed-exitcodeNo changes. Your infrastructure matches the configuration.$ echo $?0This is the mechanism a CI pipeline actually checks — if [ $? -eq 2 ]; then echo "changes pending"; fi — rather than parsing plan text output, which is meant for humans and not a stable machine format.
terraform show -json: the plan as structured data, not text
Section titled “terraform show -json: the plan as structured data, not text”terraform show -json <planfile> renders a saved plan as JSON instead of the human-readable text plan normally prints — the stable, documented format that policy tools (OPA/Conftest, Sentinel, custom CI scripts) actually parse, instead of scraping colored terminal text.
$ terraform show -json tfplan | python3 -m json.toolReal (trimmed) output for the exact local_file.config plan above:
{ "resource_changes": [ { "address": "local_file.config", "mode": "managed", "type": "local_file", "name": "config", "provider_name": "registry.terraform.io/hashicorp/local", "change": { "actions": ["create"], "before": null, "after": { "content": "v1", "filename": "./config.txt" } } } ]}resource_changes[].change.actions is the field CI policy tooling actually reads — a list like ["create"], ["update"], ["delete"], ["delete", "create"] (replace), or ["no-op"]. This exact JSON shape, and how a policy engine consumes it, is covered in full depth in Plan File and JSON Output in the Terraform Internals module.
The provider dependency lock file
Section titled “The provider dependency lock file”When terraform init resolves your required_providers version constraints, it writes the exact versions and cryptographic checksums it selected into .terraform.lock.hcl, in the same directory as your configuration.
# .terraform.lock.hcl (excerpt — Terraform manages this file's contents)provider "registry.terraform.io/hashicorp/aws" { version = "6.15.0" constraints = "~> 6.0" hashes = [ "h1:examplehashvaluegeneratedbyterraforminit=", ]}This file should be committed to version control. It is the provider-level analogue of a package-lock.json or Gemfile.lock: without it, two different runs of terraform init — your laptop today, a teammate’s laptop tomorrow, a CI runner next week — could each resolve ~> 6.0 to a different patch release, and a subtle behavior difference in the provider could produce a different plan on someone else’s machine for no visible reason. With the lock file committed, every init installs the identical versions until someone deliberately runs terraform init -upgrade to move the constraints forward.
Mental Model
Section titled “Mental Model”Every command in this lesson answers one of two questions: “what would change” (plan, show -json, -detailed-exitcode) or “make it so” (apply, destroy). Saving a plan to a file (-out) is what turns the first question’s answer into a fixed artifact that the second question’s command can execute verbatim, closing the gap between “what a human reviewed” and “what actually ran” — the single biggest reason a CI/CD pipeline uses this pattern instead of two separate plan and apply calls.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
apply with no saved plan | Fewer files/steps for a quick, interactive, one-person change | Recomputes the plan right before running it — the plan reviewed and the plan applied can differ if anything changed in between |
plan -out=tfplan then apply tfplan | The exact reviewed plan is what executes, proven above with zero confirmation prompt on apply | An extra file to manage and pass between pipeline stages; a stale plan file can go out of date if not applied promptly |
-detailed-exitcode | Machine-checkable exit code (0/1/2), proven above, instead of parsing human-oriented text output | One more flag to remember; the default exit code (always 0 on success) is easy to assume is “no changes” when it might not be |
terraform show -json | Stable, documented JSON schema for policy tools, proven above with real resource_changes[].change.actions | Verbose and not meant for a human to read directly — text plan output remains better for a human reviewer |
Common Misconceptions
Section titled “Common Misconceptions”- “
terraform apply tfplanstill shows a confirmation prompt, just against the saved plan.” Proven above: it does not prompt at all — that is the entire point of a saved plan file, and a real reason to be careful about who can runapplyagainst one. - “
terraform plan’s exit code tells you whether there are changes.” Proven above: the default exit code is0regardless of whether changes exist — only-detailed-exitcodedistinguishes0(no changes) from2(changes present) from1(error). - “
terraform show -jsonand the colored textplanoutput contain the same information in different formats.” The JSON version is the one with a documented, stable schema meant for programs; the text version is meant for humans and its exact formatting is not a stable contract to parse. - “A saved plan file (
tfplan) is just a cache — reviewing the text output once is enough, the file itself doesn’t matter.” Proven above:apply tfplanexecutes precisely that file’s contents, with no recomputation — the file itself is the guarantee, not the text a human glanced at.
Reading AI-generated code
Section titled “Reading AI-generated code”- A CI script that runs
terraform planthenterraform applyas two separate, unlinked commands (no-out/planfile) and calls that “reviewed” — proven above, this reintroduces the exact gap a saved plan closes: the applied plan can differ from whatever a human looked at. - A script checking
if [ $? -ne 0 ]after a plainterraform planto decide whether changes exist — proven above, plainplan’s exit code is always0on success; only-detailed-exitcodegives a machine-checkable signal, and forgetting that flag makes the check silently useless. - Code that parses the colored terminal text of
terraform plan(regex over+/~/-lines) to detect what changed, whenterraform show -jsonalready exposes the same information as documented, stable JSON. - A generated
apply tfplancommand with a hardcoded-auto-approveflag alongside a planfile argument — redundant and confusing:apply <planfile>never prompts in the first place, proven above, so-auto-approveis meaningless there (it only affects the no-planfile form).
🐛 Spot the bug
terraform plan -out=tfplan# ... post plan output to Slack for review ...sleep 3600 # wait an hour for a human to approve in Slackterraform apply -auto-approveShow the answer
The saved plan file tfplan is computed and never used — the pipeline runs terraform plan -out=tfplan (correctly saving a plan for review), but then applies with a bare terraform apply -auto-approve, which — proven above with a real apply tfplan run showing zero prompts only when a planfile is passed — recomputes a brand-new plan against whatever the real infrastructure looks like at apply time, not the plan a human reviewed an hour earlier in Slack. Anything that changed on AWS in that hour (someone else’s change, a drifted resource) gets silently folded into what actually applies. The fix is terraform apply -input=false tfplan (the saved file, no recomputation, and no need for -auto-approve since a planfile never prompts) instead of a fresh -auto-approve plan.