TerraformXVI · Plan Review and Saved PlansProduction Terraform
Saved Plans and the Contract
What you'll learn
- Use terraform plan -out=tfplan to serialise the plan to a file
- Distinguish the binary tfplan file from the JSON produced by terraform show -json
- Use the saved plan as the audit-trail contract between the plan stage and the apply stage
- Configure the saved plan handoff in CI so the apply executes exactly what was reviewed
- Treat the saved plan file as a sensitive artefact with its own retention and access control
Prerequisites
Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13
A Terraform plan is a single point in time: the configuration at commit SHA, the state at read time, the provider’s view of the cloud at refresh time. Once the run finishes, the plan is gone from memory. The next plan will be different. The saved plan is the file that pins the planning point in time and ships it to the apply stage so that the apply executes exactly what was reviewed.
The contract between the plan stage and the apply stage is the saved plan file. The plan file is the audit trail of what was agreed; the apply is the execution of what was agreed. The discipline of the discipline is that the apply never re-computes the plan. The saved plan is a binary artefact; its JSON rendering is a separate artefact; both are sensitive.
The plan file and the plan JSON
Two distinct artefacts, two distinct purposes.
tfplan. The binary output of terraform plan -out=tfplan.
This is the file that terraform apply reads back. It is
not human-readable. It carries the full plan graph and the
state hash recorded at plan time. When the apply runs, it
reads the file, verifies the state hash still matches the
state backend, and executes the actions stored in the file.
tfplan
|
| hash of state at plan time
| ordered list of resource_changes[] with full before/after
| resource graph metadata
| timestamp
|
v
terraform apply tfplan
|
v
validate state hash == current state hash
| match: execute
| mismatch: refuse ("Saved plan is no longer up to date")
tfplan.json. The JSON rendering of the same plan,
produced by terraform show -json tfplan > tfplan.json.
This is the artefact for the audit trail, the PR comment,
and any downstream tool that needs to query the plan
without re-running terraform plan. The JSON contains the
same data, in a structured form, minus the binary’s run-time
metadata.
terraform plan -out=tfplan
terraform show -json tfplan | jq '.resource_changes | length'
12
The JSON is what humans review; the binary is what the apply consumes.
Why the saved plan is the contract
Three races the saved plan prevents.
Race 1: drift between plan and apply
The planner sees the cloud at time T. The applier runs at time T+n. The configuration may have drifted, a teammate may have merged a parallel PR, or an operator may have touched a resource via the console. The state hash recorded in the saved plan is compared against the current state hash at apply time. A mismatch means the plan is stale; the apply is refused.
Error: Saved plan is no longer up to date
The given plan is no longer up to date. The plan was created
against a state that has since been modified, so it cannot
be applied without potentially affecting the wrong resources.
The error is non-negotiable. The remedy is to re-plan, review the new plan, save a new plan file, and re-trigger the apply.
Race 2: provider configuration drift
The planner authenticated against the cloud with one set of credentials. The applier authenticates with another. If the two sets disagree about which account or region is the target, the apply may execute in a different environment than the plan was reviewed against.
The saved plan does not directly detect this race; the discipline that prevents it is the plan-side / apply-side IAM split (the plan role is read-only, the apply role is wider; both target the same account). The saved plan prevents the apply from succeeding if anything about the state differs from what was reviewed.
Race 3: code change between plan and apply
A teammate merges a second PR after the planner saves the plan file. The second PR changes the configuration. The applier applies the saved plan. The plan was reviewed against the old configuration, but the state now reflects both PRs if the second PR was also applied. The state-hash check catches this: the plan was bound to a state hash that has since changed.
The right way to use the saved plan in CI
The pipeline has two stages:
PR opened main updated
| |
v v
lint, validate, scanners lint, validate, scanners
plan (with plan-only creds) plan (with apply creds)
terraform show -json tfplan > tfplan.json
comment on PR with plan summary upload tfplan to artifact store
wait for approval
apply (with apply creds)
terraform apply tfplan
upload tfplan.json + apply log
Three properties of the right pipeline:
- The plan side uses narrow credentials. The plan role
can
Describe*andList*the cloud resources; it cannot create, update, or delete. The apply side has wider credentials. - The apply consumes the saved plan. No re-planning. The apply reads the file, validates the state hash, executes.
- The plan file is an artifact. The file survives the plan job. The apply job downloads the artifact. The audit trail is the file.
A reference job (GitHub Actions):
plan:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.9.x
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_PLAN_ROLE }}
aws-region: eu-west-2
- run: terraform init
- run: terraform plan -input=false -no-color -out=tfplan
- run: terraform show -json tfplan > tfplan.json
- uses: actions/upload-artifact@v4
with:
name: tfplan-production
path: |
tfplan
tfplan.json
apply:
needs: plan
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: apply-production # manual approval
concurrency: terraform-apply-production
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_APPLY_ROLE }}
aws-region: eu-west-2
- run: terraform init # re-init against production backend
- uses: actions/download-artifact@v4
with:
name: tfplan-production
- run: terraform apply -input=false -no-color tfplan | tee apply.log
- uses: actions/upload-artifact@v4
with:
name: tfapply-production
path: |
tfplan
tfplan.json
apply.log
Three properties the apply job has:
- One
initper stage. The apply re-runs init against the production backend. Reusing the plan stage’s init directory risks a different backend or different provider versions. - The apply uses the saved plan. No re-computation. The apply is the plan that was reviewed.
- The apply re-uploads the plan file. The file survives the apply. The audit trail is the file even if the runner is destroyed.
The sensitivity of the plan file
The plan file is sensitive in three ways:
- Resource IDs. The plan contains the ARNs, instance IDs, and resource IDs that the apply will touch. An attacker who reads the file knows exactly which resources are in scope and which providers were consulted.
- Attribute values. The plan contains the full
before/after for every attribute. A
~ aws_iam_rolechange that widens permissions shows the before (narrow) and the after (wide). A~ aws_s3_bucket_policychange that opens the bucket shows the before (private) and the after (public). The plan is the proposal; reading it tells you what was about to happen. - Sensitive attribute values. The plan file always
contains the resolved value of sensitive attributes: the
database password, the API key, the TLS private key.
Terraform redacts them from the human-readable output as
(sensitive value), but the saved file holds them in cleartext andterraform show -jsonrenders them in full. No flag changes this. The redaction is a property of the terminal rendering, not of the artefact.
# READ-ONLY: list the addresses whose plan carries at least one
# sensitive value. after_sensitive mirrors the after object with
# every sensitive leaf replaced by true.
terraform show -json tfplan | jq -r '.resource_changes[]
| select([.change.after_sensitive | .. | select(. == true)] | length > 0)
| .address'
"aws_db_instance.production"
"aws_secretsmanager_secret.api_key"
Three operational consequences:
- Artifact retention. The plan file should be retained for the audit window (typically 1 year) and not longer. The plan is sensitive PII-adjacent data; retaining it beyond the audit window is unnecessary risk.
- Artifact access. The plan file should be readable by the apply role and the audit role only. It should not be world-readable in the artifact store.
- Local handling. A plan file on a developer laptop
should be removed after review. A plan file in
/tmpon a runner should be in a memory-only mount, or removed immediately after the artifact upload.
Validating the handoff
# Confirm the saved plan is current.
terraform plan -detailed-exitcode
# exit 0 = no changes (steady state)
# exit 2 = drift or new plan
# Confirm the saved plan reads back cleanly.
terraform show tfplan | head -20
# Confirm the saved plan matches the JSON.
terraform show -json tfplan > /tmp/plan-after.json
diff <(jq -S . tfplan.json) <(jq -S . /tmp/plan-after.json)
A diff between the JSON produced at plan time and the JSON produced by re-reading the binary should be empty. A non-empty diff means the binary has been edited (which should not happen) or the binary is corrupted (which is a runbook case).
Production failure modes
-
The plan file was not uploaded to the artifact store. The apply job did not have the file; the apply attempted a
terraform plan -out=tfplan && terraform apply tfplanpattern, which is a fresh plan, not the reviewed plan. The fix is to uploadtfplanas an artifact and download it on the apply side. -
The state hash changed between plan and apply. The saved plan was rejected with “Saved plan is no longer up to date”. A teammate’s parallel merge changed the state. The fix is to re-plan, re-review, save a new plan file, and re-trigger the apply.
-
A sensitive value appeared in the plan output and was scraped by a bot. The PR comment contained the database password. The fix is to scrub sensitive values from PR comments using a redaction step before the comment is posted. The plan JSON is never redacted by Terraform, so the pipeline has to do it, or route the unredacted artefact to a cleared channel instead of a PR comment.
-
The artifact store preserved the file for two years. The compliance window is one year. The fix is a retention policy on the artifact bucket and a quarterly check that the retention is enforced.
-
The plan file was world-readable in the artifact store. A scanner in CI found the bucket policy. The fix is to enforce
privateACL on the artifact store and rely on short-lived OIDC credentials for the apply job. -
The developer ran the plan locally, saved it to
/tmp/tfplan, then committed the configuration. The pre-commit hook ignored/tmpbut the developer’s shell history retained the command. The fix is to never store the plan file outside the CI artifact store; never paste the apply command into a terminal that retains history.
Security and performance
Security: the plan file is the highest-sensitivity artefact in the pipeline. It contains the proposed change set in full detail; it may contain resolved credential values. The artifact store must enforce private access. The apply job must use OIDC; the credential must not be persisted.
Performance: the plan file is small (kilobytes for a small estate, megabytes for a very large estate). The artifact upload and download are not bottlenecks. The bottleneck is the plan itself: provider API calls for a refresh, and the graph walk. The saved plan file does not change those costs.
Production guidance
- One stage plans. One stage applies. The file is the handoff.
- The plan side has narrow credentials. The apply side has wider credentials. The split is the production control.
- The plan file is an artifact with retention, access control, and redaction. It is not a transient file.
- The JSON rendering is for the audit trail and the PR comment. The binary is for the apply.
- The plan artefact is never redacted. Terraform redacts only its terminal rendering; the saved file and the JSON hold sensitive values in cleartext and no flag changes that. Redact in the pipeline before anything is posted, or route the unredacted artefact to a cleared channel.
- Never re-compute the plan at apply time. The plan was reviewed; the apply is the plan.
What comes next
The next lesson is the production plan review as a whole: what to look at in a Terraform PR, what to ignore, the CODEOWNERS setup, and the PR template that forces the right questions.
Verification
Run the plan stage against a throwaway account; confirm the saved plan file is produced and the JSON is uploaded as artifacts. Then run the apply stage against the same account; confirm the apply consumed the binary, the state hash matched, the apply output was captured, and the file was re-uploaded. Destroy the test resources when finished.
Knowledge check · 7 questions
Q1. What is the right handoff between the plan stage and the apply stage in a CI pipeline?
Q2. What does terraform show -json tfplan > tfplan.json produce, and how does it differ from the binary?
Q3. Why is the saved plan file considered a sensitive artefact?
Q4. terraform plan -out=tfplan writes the plan file and then exits, leaving the apply as a separate terraform apply tfplan step.
Q5. Which of the following should be enforced for the saved plan file in production? (Select all that apply.)
Q6. An operator runs terraform plan -out=tfplan followed by terraform apply tfplan. The apply errors with 'Saved plan is no longer up to date'. What is the right action?
Q7. A PR bot posts the plan JSON to the PR comment. The JSON contains an unredacted RDS master password because the configuration uses the value from a data source. The PR was merged. What is the first production action?
Passing score: 75%. Answers are checked in this browser.