TerraformXXII · CI/CD for Production TerraformCI/CD plan artifact
Plan Artifacts as the Audit Trail
What you'll learn
- Write a plan to a binary file with `terraform plan -out` and apply that exact file
- Pass the saved plan from the plan stage to the apply stage as a CI artifact
- Identify the sensitive data that a plan file may contain and treat it accordingly
- Configure CI-safe defaults: `-no-color`, `-input=false`, and `-lock-timeout`
- Distinguish the saved plan file from the JSON output and from the human-readable plan text
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
The plan artifact is the contract between the engineer who
proposed the change and the engineer who approved it. It is
the binary file that terraform plan -out=tfplan writes and
that terraform apply tfplan later executes. In a CI
pipeline, the artifact travels from the plan stage to the
apply stage as a build artifact. The reviewer sees the human
output; the apply sees the binary file. Both must agree.
This lesson covers how to write the plan file, how to ship it through the pipeline, what it contains (including the sensitive parts), and the CI-safe flags that keep the artifact machine-readable and the apply non-interactive.
Why the plan file is the audit trail
The apply either matches the plan or it does not. If the apply uses the saved plan file, the answer is yes, exactly: the binary file encodes the proposed changes, the resource addresses, the dependencies, and the state hash. The apply executes the file; it does not re-compute the plan.
plan stage apply stage
--------- -----------
terraform plan -out=tfplan terraform apply tfplan
| |
v v
plan file on runner download tfplan artifact
| |
v v
upload as artifact terraform apply tfplan
| |
v v
reviewer reads human apply executes the
output, approves file. Audit trail =
plan file = human
output.
Without the plan file, the apply computes its own plan:
plan stage apply stage
--------- -----------
terraform plan terraform apply
| |
v v
human output on PR apply computes its own
reviewer approves plan. The plan may
differ from what was
reviewed.
The two cases are not equivalent. In the first, the apply executes exactly what was reviewed. In the second, the apply may execute a different plan if the configuration or state changed in the seconds or minutes between the plan and the apply. The saved-plan pattern is the production control that closes this gap.
The plan file format
The plan file is a binary file in Terraform’s internal plan
representation. It is not JSON; do not try to edit it. The
format is documented in the Terraform internals guide. The
key fields, when you convert the file to JSON with
terraform show -json tfplan:
{
"format_version": "1.2",
"terraform_version": "1.9.5",
"resource_changes": [
{
"address": "aws_instance.web",
"type": "aws_instance",
"name": "web",
"change": {
"actions": ["create"],
"before": null,
"after": {
"ami": "ami-0abc123def456789",
"instance_type": "t3.small",
"tags": {
"Name": "web-01",
"Environment": "production"
}
}
}
}
],
"configuration": {
"provider_config": {
"aws": {
"name": "aws",
"expressions": {
"region": { "constant_value": "eu-west-2" }
}
}
},
"root_module": { ... }
},
"state": { ... }
}
Three parts of this matter:
resource_changes: the proposed changes per resource. The apply reads this to know what to create, update, or destroy.configuration: the configuration root module. The apply reads this to know which providers and modules are involved.state: the state at the time of plan. The apply reads this to verify the state has not changed since the plan.
If state does not match the current state at apply time, the
saved-plan lock fires and the apply is rejected.
Writing the plan file in CI
The plan stage command:
terraform plan \
-out=tfplan \
-no-color \
-input=false \
-lock-timeout=300s
The flags explained:
-out=tfplan: write the plan to a binary file. The apply will read this file. Without it, the apply computes its own plan.-no-color: strip ANSI colour codes from the output. The plan output is part of the job log; colour codes make it unreadable in a CI log viewer. They also break programmatic parsing of the output.-input=false: refuse to prompt for missing variables. In CI, the runner has no terminal. If a variable is missing, the plan errors immediately rather than hanging waiting for stdin. The CI fails fast.-lock-timeout=300s: wait up to 5 minutes for the state lock before failing. A short timeout causes the plan to fail under transient contention; a long timeout masks a stuck lock. 5 minutes is a reasonable production default for small estates; longer for larger ones.
The plan file is uploaded as a build artifact:
- uses: actions/upload-artifact@v4
with:
name: tfplan-${{ matrix.env }}
path: tfplan
retention-days: 30
The retention period is the audit-trail lifetime. 30 days is a common minimum for SOX-regulated environments; longer for regulated industries. The artifact is the only record of the plan after the job ends.
The human-readable plan output
The plan file is binary. The reviewer cannot read it. Convert it to JSON and post a summary on the PR:
terraform show -json tfplan > plan.json
# Extract a summary
jq -r '
.resource_changes
| group_by(.change.actions[0])
| map({action: .[0].change.actions[0], count: length})
| .[]
| "| \(.action) | \(.count) |"
' plan.json
The result is a table that the PR reviewer reads in seconds:
| Action | Count |
|--------|-------|
| create | 3 |
| update | 1 |
| delete | 0 |
A second step posts the full plan JSON to the PR as a collapsible section, or attaches the binary as a downloadable artifact.
- uses: actions/github-script@v7
with:
script: |
const plan = JSON.parse(require('fs').readFileSync('plan.json', 'utf8'));
const summary = plan.resource_changes.reduce((acc, r) => {
const action = r.change.actions[0];
acc[action] = (acc[action] || 0) + 1;
return acc;
}, {});
const body = '### Terraform plan\n\n' +
'| Action | Count |\n|--------|-------|\n' +
Object.entries(summary).map(([k, v]) => `| ${k} | ${v} |`).join('\n');
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body,
});
The summary is what the reviewer reads. The full JSON is what the auditor reads. Both are produced from the same plan file.
Applying the plan file in CI
The apply stage command:
terraform apply \
-input=false \
-no-color \
tfplan
The flags explained:
-input=false: refuse to prompt. The apply will not ask “Do you want to perform these actions?”. The CI controls approval through the GitHubenvironmentgate, not through the CLI prompt.-no-color: strip ANSI colour codes. The apply output is captured to the job log.tfplan: the positional argument. The apply reads the plan file and executes it. No re-computation.
Note the absence of -auto-approve. The saved plan file
substitutes for the interactive approval; the apply still
expects an explicit “yes” if it were interactive. With
-input=false, there is no prompt, and the saved plan file
is the contract.
Sensitive data in the plan file
The plan file contains the planned configuration. If the configuration includes sensitive values (a database password passed as a variable, a private key written to a resource attribute, a tag that contains a customer name), those values are in the plan file.
resource "aws_db_instance" "main" {
# ...
password = var.db_password
}
variable "db_password" {
type = string
sensitive = true
}
The sensitive = true flag suppresses the value in the
human-readable output. It does not suppress it in the plan
file. The plan file still contains the resolved value of
var.db_password because the apply needs it to set the
password attribute on the RDS instance.
Three consequences:
- The plan artifact is a credential. Treat it like one. Do not store it in a public artifact bucket. Do not post it to a public PR. Do not leave it in a runner workspace after the job.
- The artifact retention is a credential lifetime. A 365-day retention is a 365-day window during which the plan file contains the production database password. Match the retention to the operational need, not to a round number.
- The downloaded artifact on the apply runner is a credential. The runner filesystem is ephemeral, but the file is on disk until the runner is destroyed. Confirm the runner is not reused for untrusted workloads and that the disk is encrypted.
The plan file is not the JSON output
A common confusion:
# This writes the plan to a binary file.
terraform plan -out=tfplan
# This writes the plan to JSON for display.
terraform show -json tfplan > plan.json
The binary file is the artifact. The JSON is the human-readable view. The apply uses the binary file. The reviewer reads the JSON. The auditor reads both.
A common mistake is to upload the JSON as the artifact and
expect the apply stage to read it. It does not. The apply
stage must download the binary file and pass it to
terraform apply.
Drift between the plan file and reality
A plan file is bound to the state at the moment of plan. If the state changes between the plan and the apply, the apply is rejected:
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.
Three things can change the state between the plan and the apply:
- Another apply ran in parallel. The state backend lock should have prevented this. If the lock was misconfigured (or two applies against different state files were orchestrated without a runner-level concurrency group), one apply writes to the state before the other reads it. The second apply’s saved plan is stale.
- A manual state edit.
terraform state mvorterraform state rmfrom a terminal. The state file changed; the saved plan file is stale. - A drift remediation.
terraform apply -refresh-onlyruns against the state to update it after a manual change in the cloud. The state changed; the saved plan file is stale.
In each case, the right action is to re-plan. The new plan file is the new contract; the new apply uses the new file.
Validation commands
Confirm the plan artifact is wired correctly:
# 1. Plan produced a binary file.
ls -la tfplan
# -rw-r--r-- 1 runner docker 12345 Aug 13 11:42 tfplan
# 2. The file is a valid plan.
terraform show tfplan | head -20
# aws_instance.web: Refreshing state... [id=i-0abc123def456789]
#
# Terraform used the selected providers to generate the following
# plan. The plan is a binary file that is not human-readable.
# 3. The JSON view matches the human output.
terraform show -json tfplan | jq '.resource_changes | length'
# 4. The apply uses the saved plan and exits 0.
terraform apply -input=false tfplan
# Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
# 5. The state hash matches the plan's state.
terraform plan -detailed-exitcode
# exit 0 = no changes (steady state)
A plan file that does not exist, is empty, or is a stale binary is a CI bug. The plan stage should fail before the artifact upload if the file is missing.
Production failure modes
-
Plan file missing from artifact. The plan stage uploaded the JSON view but not the binary file. The apply stage errors with
The given path is not a directory. The fix is to upload the binarytfplanfile, not just the JSON. -
Plan file is stale at apply time. The state changed between the plan and the apply. The saved-plan lock fires. The fix is to re-plan and re-upload the artifact. Do not force-unlock and apply the stale file.
-
Plan file contains a sensitive variable, the artifact is public. The plan file is downloadable by anyone with access to the artifact URL. The fix is to audit the artifact bucket policy and rotate the sensitive value.
-
Plan file’s state hash does not match the apply state. This is the same as the stale-plan failure mode, but with a different cause: the state backend was switched between the plan and the apply. The fix is to use one
initper stage, against the same backend. -
Apply used
-auto-approveinstead of the saved plan. The apply computed its own plan. The reviewer approved a different plan. The fix is to pass the saved plan file as the positional argument toterraform apply, and to remove-auto-approvefrom the apply command. -
Plan output was coloured in the log. The log is full of ANSI escape codes. The reviewer cannot read the plan. The fix is
-no-coloron the plan and apply commands.
What comes next
The next lesson covers multi-environment pipelines: per-env backends, per-env variable files, the workspaces versus OSS backends question, the promotion model, and how to model region and account differences.
Verification
Run the plan stage on a one-resource module and confirm:
# The binary file exists.
ls -la tfplan
# file tfplan
# tfplan: data
# The JSON view is consistent with the binary.
terraform show -json tfplan | jq '.format_version'
# "1.2"
# The apply uses the binary file.
terraform apply -input=false tfplan
# Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
# The plan is idempotent afterwards.
terraform plan -detailed-exitcode
# ; echo $?
# 0
Then run the same module with a sensitive variable set to a
known value, inspect the binary file with strings, and
confirm the value appears. This is the operational test for
the “treat the plan file as a credential” warning above.
Knowledge check · 7 questions
Q1. Why does a CI apply stage pass the saved plan file as a positional argument instead of using `-auto-approve`?
Q2. What does `-input=false` do in a CI plan command?
Q3. A saved plan file holds the resolved value of a `sensitive = true` variable, so the artifact has to be protected like a credential.
Q4. What is the difference between the binary plan file and the JSON plan output?
Q5. Which of the following must be configured for a CI-safe plan command? (Select all that apply.)
Q6. A plan file's state hash differs from the apply-time state. What does Terraform do?
Q7. A team stores plan artifacts in an S3 bucket with a misapplied bucket policy that allows public read. The plan includes a database password variable. What is the impact?
Passing score: 75%. Answers are checked in this browser.