Terraform Associate saved plan files, from plan -out to apply, show -json, and the stale plan
Passing the file is the approval. The state it was built on must not move.
A plan you review now and apply later is a different object from the plan you approve at the prompt. The exam and your pipeline both depend on knowing how it differs.
§I. Frame
In an interactive terminal, terraform apply hides a gap. It builds a plan, prints it, asks you to type yes, and applies it, all within a few seconds in one process. The plan you read and the plan that runs are the same object, so you never have to ask what happens between them.
Saved plans open that gap. You run a plan at 10:00, a colleague reviews it, and someone applies it at 10:40. In those forty minutes the state may change, someone may pass different variables, and the file may have been copied to a place it should not be. Every behavior in this lesson answers one of those problems.
Two terms first. A speculative plan is what you get from terraform plan without -out. The docs describe it as "a description of the effect of the plan but without any intent to actually apply it." A saved plan is what you get with -out=FILE, which you "can later execute by passing the file to terraform apply as an extra argument." The docs say this two-step workflow "is primarily intended for when running Terraform in automation."
The distinction matters because a speculative plan can go out of date. The plan documentation warns that "other changes made to the target system in the meantime might cause the final effect of a configuration change to be different than what an earlier speculative plan indicated, so you should always re-check the final non-speculative plan before applying." A saved plan lets you apply exactly what was reviewed, and Terraform refuses to apply it if the state it was built against has moved. You will see both halves of that in the lab.
§II. Foundations
What a saved plan is
terraform plan -out=FILE writes the generated plan "in an opaque file format." Two details from the docs are worth memorizing:
- A typical filename is
tfplan, but Terraform accepts any name. Do not use a suffix Terraform recognizes as another format. A.tfsuffix makes Terraform "try to interpret the file as a configuration source file", which is the wrong thing to do to a plan. - The file is more than a list of actions. The docs say it "does contain your full configuration, all of the values associated with planned changes, and all of the plan options including the input variables." The automation tutorial adds that it holds "the state that the plan applies to."
That list explains most of the behaviors below. A plan file carries its own copy of the configuration, state and variables, so applying it does not need to re-decide anything.
Two apply modes
terraform apply has two modes, and the apply documentation names them.
Automatic Plan Mode is what you know. With no plan file, Terraform "automatically creates a new execution plan as if you had run terraform plan, prompts you to approve that plan, and performs the indicated operations."
Saved Plan Mode starts when you pass a plan file. Terraform "performs the operations in the saved plan without prompting you for confirmation." There is no approval prompt because the review already happened, at the point where you or a reviewer looked at the plan. The docs point you at terraform show for that: "Use terraform show to inspect a saved plan file before applying it."
Why -auto-approve means nothing here
-auto-approve skips the approval prompt in automatic mode. With a plan file there is no prompt to skip. The docs say Terraform "ignores this option when you pass a previously-saved plan file. This is because Terraform interprets the act of passing the plan file as the approval."
That sentence shapes the safety model. Approval is attached to the file, not to a flag. Whoever can hand a plan file to terraform apply can approve it.
Why extra options are refused
The apply docs say that "when using a saved plan, you cannot specify any additional planning modes or options. These options only affect Terraform's decisions about which actions to take, and the plan file contains the final results of those decisions."
Planning options such as -var, -destroy or -refresh-only shape the decisions made at plan time. The plan file records the outcome of those decisions. If apply accepted new values, the executed change would no longer match the reviewed one. The lab shows what this refusal looks like.
One more fact from the apply docs: "Terraform does not automatically roll back a partially-completed apply." That is one more reason to read a plan before the file goes anywhere.
§III. The lab, step by step
The lab uses only Terraform's built-in terraform_data resource, so there is no provider download and no cloud account. The configuration:
terraform {
required_version = ">= 1.5"
}
variable "release" {
type = string
default = "v1"
}
variable "db_password" {
type = string
sensitive = true
default = "example-not-a-real-secret"
}
# terraform_data is built in: no provider download, no cloud account.
resource "terraform_data" "release" {
input = var.release
}
resource "terraform_data" "db" {
input = var.db_password
}
output "release" {
value = terraform_data.release.output
}
The transcript comes from run.sh on Terraform v1.14.3 (darwin_arm64) with local state. The script exports TF_IN_AUTOMATION=1, and every [exit N] line is the real exit code. The exam page says the 004 exam tests Terraform version 1.12. The lab ran on 1.14.3, not 1.12, so treat the exact error wording below as that version's text.
1. Save a plan, then read it back
$ terraform plan -input=false -out=v1.tfplan
...
# terraform_data.db will be created
+ resource "terraform_data" "db" {
+ id = (known after apply)
+ input = (sensitive value)
+ output = (known after apply)
}
# terraform_data.release will be created
+ resource "terraform_data" "release" {
+ id = (known after apply)
+ input = "v1"
+ output = (known after apply)
}
Plan: 2 to add, 0 to change, 0 to destroy.
...
[exit 0]
-input=false tells Terraform not to prompt for root module variables, which matters in automation where nobody can answer. The terminal output hides the password as (sensitive value). Keep that in mind for §IV.
Then:
$ terraform show v1.tfplan
...
Plan: 2 to add, 0 to change, 0 to destroy.
...
[exit 0]
terraform show printed the same plan, read from the file. This is the review step: the reviewer reads the exact plan that apply will run, without planning again.
2. The JSON view
$ terraform show -json v1.tfplan | jq (resource_changes)
{"address":"terraform_data.db","actions":["create"]}
{"address":"terraform_data.release","actions":["create"]}
For plan files, the docs say terraform show -json "shows a JSON representation of the plan, configuration, and current state." Here jq pulled out each resource address and its list of actions. This is the form a script or policy tool can read, which §V picks up. One caveat from the show docs: "If you are viewing a plan, it must be created without -refresh=false."
3. What is inside the file
$ unzip -l v1.tfplan
tfplan
tfstate
tfstate-prev
tfconfig/m-/main.tf
tfconfig/modules.json
.terraform.lock.hcl
In this version, unzip could list the plan file. It held entries named tfplan, tfstate and tfstate-prev, a copy of main.tf under tfconfig/, a module manifest and the dependency lock file. That is what this version wrote. The docs call the format opaque, so do not build tools that depend on this layout. Use terraform show and terraform show -json, which are the supported ways in. The listing is useful as evidence for the docs' claim that the file contains "your full configuration" and the state it applies to.
4. The secret is in the file
$ grep -c example-not-a-real-secret (plan file, show -json)
2
1
The first count searches the unzipped plan file, and the second searches the show -json output. grep -c counts matching lines, not occurrences, so read these as "found" rather than as precise tallies. Both found the sensitive default example-not-a-real-secret, even though the terminal plan printed (sensitive value). §IV covers what follows from this.
5. Apply with a new variable: refused
$ terraform apply -input=false -var release=v9 v1.tfplan
Error: Value for undeclared variable
A variable named "release" was assigned on the command line, but the root
module does not declare a variable of that name. To use this value, add a
"variable" block to the configuration.
[exit 1]
The message is misleading. release is declared in main.tf. Do not go looking for a missing variable block.
The real reason is the one from the apply docs: a saved plan already contains its variables and options, so no new ones are accepted. In saved plan mode, -var is an unexpected extra option, and this version's error wording for it happens to talk about undeclared variables. If a question or a pipeline log shows this error alongside a plan file argument, the fix is to re-plan with the values you want, then apply the new file.
6. Apply with no prompt
$ terraform apply -input=false v1.tfplan
terraform_data.release: Creating...
terraform_data.db: Creating...
terraform_data.release: Creation complete after 0s [id=2ee5f8b3-640d-9467-7bb6-c153d15ec442]
terraform_data.db: Creation complete after 0s [id=6cbf2a03-5022-22fe-115e-a78bc8ae445b]
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
Outputs:
release = "v1"
[exit 0]
No Do you want to perform these actions? and no -auto-approve. Passing the file was the approval. The result is the 2 additions the earlier plan displayed and nothing else.
7. Two plans from one state, and the stale one
Now the state holds release = "v1". The script saves two plans from that same state, with -detailed-exitcode on each:
$ terraform plan -input=false -detailed-exitcode -var release=v2 -out=v2.tfplan
...
# terraform_data.release will be updated in-place
~ resource "terraform_data" "release" {
id = "2ee5f8b3-640d-9467-7bb6-c153d15ec442"
~ input = "v1" -> "v2"
~ output = "v1" -> (known after apply)
}
Plan: 0 to add, 1 to change, 0 to destroy.
...
[exit 2]
$ terraform plan -input=false -detailed-exitcode -var release=v3 -out=v3.tfplan
...
~ input = "v1" -> "v3"
...
[exit 2]
Exit code 2 is the -detailed-exitcode signal for "Succeeded with non-empty diff (changes present)." The docs list the full set: 0 for an empty diff, 1 for an error, 2 for changes present. Both plans are valid on their own, built from the same starting state.
Apply v3.tfplan first:
$ terraform apply -input=false v3.tfplan
terraform_data.release: Modifying... [id=2ee5f8b3-640d-9467-7bb6-c153d15ec442]
terraform_data.release: Modifications complete after 0s [id=2ee5f8b3-640d-9467-7bb6-c153d15ec442]
Apply complete! Resources: 0 added, 1 changed, 0 destroyed.
...
release = "v3"
[exit 0]
Now try v2.tfplan, still sitting on disk:
$ terraform apply -input=false v2.tfplan
Error: Saved plan is stale
The given plan file can no longer be applied because the state was changed by
another operation after the plan was created.
[exit 1]
Applying v3 changed the state. The v2 file claimed to start from the old state, and Terraform refused to run it against the new one. The guard is the point of the design: without it, an old plan could silently overwrite a newer change.
The check is not about which plan is newer or which version number is bigger. It is about which plan was applied first. Had you applied v2 first, v3 would be the stale one. This is the situation the automation tutorial describes: "When a plan is applied, any other existing plans that were produced against the same state are invalidated, since they must now be recomputed relative to the new state." It recommends "allow only one plan to be outstanding at a time." The recovery is to plan again.
8. Plan again
$ terraform plan -input=false -detailed-exitcode -var release=v3
...
No changes. Your infrastructure matches the configuration.
...
[exit 0]
Exit 0, no changes. This plan has no -out, so it is speculative, and it confirms that state and configuration agree. In a pipeline, exit code 0 means there is nothing to apply.
§IV. Plan files are secrets
The lab already showed the problem: the terminal said (sensitive value), and the file did not hide anything.
The plan docs are direct about it: "If your plan includes any sort of sensitive data, even if obscured in Terraform's terminal output, it will be saved in cleartext in the plan file. You should therefore treat any saved plan files as potentially-sensitive artifacts." The sensitive = true setting in main.tf controls what Terraform prints. It does not control what Terraform writes into the file.
The show docs add the same warning for the JSON view: "When using the -json command-line flag, any sensitive values in Terraform state will be displayed in plain text." So terraform show -json plan is a second way to reach the same data. Pasting that output into a ticket or a chat posts the secret.
Brikman, in Terraform: Up and Running (3rd ed.), puts it this way: "any secrets you pass into your Terraform resources and data sources will end up in plain text in your Terraform plan files!" He gives two rules:
- Encrypt your Terraform plan files, in transit and on disk.
- Strictly control who can access your plan files.
One related habit from the automation tutorial: "For provider authentication credentials, it is recommended to use environment variables instead where possible since these are not included in the plan." The file holds the configuration, the state and the variables passed to terraform plan. Credentials that arrive through the environment stay out of it.
The practical rule is to give a plan file the same access controls as the state file, because it holds the same values and more.
§V. Plans in automation
The tutorial Running Terraform in automation lays out a four-step path: initialize, produce a plan, have a human operator review that plan, apply the changes described by the plan. The commands:
terraform init -input=false
terraform plan -out=tfplan -input=false
terraform apply -input=false tfplan
The review step sits between the second and third commands. That is the step the saved plan makes possible.
Some supporting details:
- **
-input=false** disables prompting for root module input variables, which is "particularly useful when running Terraform in non-interactive automation systems." - **
TF_IN_AUTOMATION**: when set to any non-empty value, "Terraform makes some minor adjustments to its output to de-emphasize specific commands to run." The lab script sets it. - **
-detailed-exitcode**: lets a CI job branch on the result. Exit 0 means nothing to do, 1 means an error, and 2 means changes are present and a review or apply stage should follow. - One outstanding plan: as in step 7, applying one plan invalidates others built from the same state.
Moving a plan between machines
If the plan and apply run on different machines, the tutorial gives constraints, all of which follow from the file's contents:
- Archive the whole working directory, including
.terraform, and extract it "at the same absolute path", because "the saved plan file can contain absolute paths to child modules." - "Terraform assumes that the plan will be applied on the same operating system and CPU architecture as where it was created."
- "An error will be produced if Terraform or any plugins are upgraded between creating and applying a plan."
Plans that are never applied
For pull request checks, the tutorial says "the -out option is not used in this scenario because a plan produced for code review purposes will never be applied." A plan for review is speculative by design. There is no reason to create a file that would then need protecting.
HCP Terraform
Remote execution works differently: "You cannot apply a saved plan to HCP Terraform remote operations. You can review probable infrastructure changes by triggering a speculative plan, but speculative plans are not applyable."
JSON for policy checks
Brikman's book shows the pattern for testing plans with OPA: terraform plan -out tfplan.binary, then, because "OPA only operates on JSON," convert the plan with terraform show -json tfplan.binary > tfplan.json. The show -json output from step 2 is how a saved plan reaches a policy engine. That output carries the same plain-text values as §IV warns about, so the JSON file needs the same handling as the plan file.
§VI. Exam lens
This material serves these objectives on the current exam content list (the 004 list, which the old 003 review URL now serves):
- 3a, "Describe the Terraform workflow"
- 3d, "Generate and review an execution plan for Terraform"
- 3e, "Apply changes to infrastructure with Terraform"
- 4h, "Understand best practices for managing sensitive data"
On the older 003 list, this material sits under the core workflow objective. The 004 page states that the exam "Tests on Terraform version 1.12."
Traps to watch for:
- Speculative versus saved.
terraform planalone makes a speculative plan with no intent to apply. Only-out=FILEproduces somethingapplycan run later. - **
-auto-approvewith a plan file.** It is ignored. Passing the file is the approval, and no prompt appears. - **
-varor-targetwith a saved plan.** Apply accepts no additional planning modes or options, because the plan holds the final results of those decisions. The lab tested-varand got exit 1. The apply docs name-destroyand-refresh-onlyas planning modes. The docs' reason (options that only affect which actions to take) is what you would apply to-targetin a question, but this lab did not run-target. - The stale plan. Two plans from one state: the first one applied wins, and the second fails with "Saved plan is stale." Re-plan.
- **A
.tfsuffix on a plan file.** Terraform would try to read it as configuration.tfplanis the conventional name. - Sensitive values.
sensitive = truehides terminal output only. The plan file holds the value in cleartext, and so doesterraform show -json.
§VII. Practice Questions
terraform plan with no extra options and save nothing. What kind of plan did you produce? A) A saved plan that apply will pick up automatically B) A speculative plan, a description of the effect without any intent to apply it C) An opaque file named tfplan D) A plan that locks the state until you apply-out=FILE, Terraform creates a speculative plan. Only a plan written with -out can be passed to terraform apply later.terraform apply -auto-approve tfplan, where tfplan was created by terraform plan -out=tfplan. What does -auto-approve do? A) Skips the approval prompt that would otherwise appear B) Re-plans before applying C) Is ignored, because passing a saved plan file is itself the approval D) Causes an error because the two cannot be combined-auto-approve for a previously saved plan file, since it interprets the act of passing the file as the approval. No confirmation prompt appears either way.terraform plan -out=v1.tfplan, someone runs terraform apply -var release=v9 v1.tfplan and gets exit code 1. What is the underlying reason? A) The variable was never declared in the configuration B) A saved plan already contains its variables and options, so apply accepts no additional ones C) -var is only valid with -auto-approve D) The plan file must be renamed firsta.tfplan and b.tfplan, were saved from the same state. You apply b.tfplan successfully, then run terraform apply a.tfplan. What happens, and what should you do? A) It applies, because both plans were valid when created B) It fails as a stale plan, because the state changed after the plan was created, so create a new plan C) It applies only the differences from b.tfplan D) It prompts you to choose which plan winssensitive = true, and the terminal plan shows (sensitive value). Which statement about the saved plan file is correct? A) The value is encrypted inside the file B) The value is omitted from the file C) The value is stored in cleartext in the file, and terraform show -json also displays it in plain text D) The value is only stored if -var was usedterraform plan -detailed-exitcode and the exit code is 2. What does that mean? A) An error occurred B) The plan succeeded with an empty diff C) The plan succeeded and changes are present D) The plan file is stalev2.tfplan and v3.tfplan returned 2.§VIII. Closing
Saving a plan splits one interactive moment into two events, review and apply, and the rules in this lesson hold the two together. The file carries the decisions, so apply accepts no new options. The file counts as the approval, so no prompt appears. The state it was built against must not have moved, so a stale plan is refused. And since the file holds your configuration, state and variables in the clear, it deserves the same care as the state itself.
Related
- HashiCorp docs, terraform plan
- HashiCorp docs, terraform apply
- HashiCorp docs, terraform show
- HashiCorp tutorial, Running Terraform in automation
- HashiCorp, Terraform Associate exam content list
- Brikman, Terraform: Up and Running, 3rd ed. (2022)
- Paired lesson: Terraform on Azure: Key Vault audit logs through diagnostic settings