Git, CI/CD & GitOpsL · Terraform CIPlanArtifact
Plan as artifact and PR comment — saving the plan, posting it, and the review contract
What you'll learn
- Capture a plan as a binary with terraform plan -out=tfplan and apply that exact artifact
- Convert a plan to JSON with terraform show -json tfplan | jq . for parsing and posting
- Implement the comment-on-PR pattern and recognise it as the review contract
- Explain why re-planning between review and apply is a re-review, not a re-run
Prerequisites
Practice
Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x
A terraform plan is a deterministic function of three inputs: the configuration, the state, and the cloud provider’s view of the world. Given the same three inputs, the same plan is produced. Given different inputs, the plan differs. The PR review is a review of a specific plan output; if the apply step runs against a different plan - because state moved, because the cloud drifted, because someone else applied a competing change in the meantime - the review was of a different change than the one being applied. Saving the plan as an artifact and applying that artifact is the contract that makes the review meaningful. This lesson is the closing piece of Part L.
Capturing the plan as a binary
The flag -out=tfplan writes the plan to a file rather than printing it. The file is a binary, not text: it is the serialised representation of the planned actions against a specific state file. The apply step reads that file and executes it without re-planning.
terraform plan -input=false -lock-timeout=300s -out=tfplan
-input=falsedisables interactive prompts; CI is non-interactive.-lock-timeout=300swaits up to five minutes for the state lock to be released; without this flag, the default is zero, and a contended lock fails the plan immediately.-out=tfplanwrites the binary plan to the named file.
flowchart LR
A[Configuration + state] --> B[terraform plan -out=tfplan]
B --> C[binary plan artifact]
C --> D[terraform show -json tfplan]
D --> E[JSON plan for posting]
E --> F[Post as PR comment]
C --> G[terraform apply tfplan]
G --> H[Apply exactly what was reviewed]
The apply step that consumes the artifact:
terraform apply -input=false tfplan
There is no -out and no re-plan. The apply takes the binary plan file and executes it against the live state, which has been locked for the duration. The plan that was reviewed is the plan that was applied.
Converting the plan to JSON
The binary plan is not human-readable. For PR review and for tooling, the canonical form is JSON:
terraform show -json tfplan | jq .
The | jq . is optional - it pretty-prints the JSON for readability. The structured output contains the resource changes (resource_changes[]), the planned actions (create, read, update, delete, replace), the planned attribute values after the change, and the addresses of every affected resource. Most CI integrations parse this JSON, extract the summary (N to add, M to change, K to destroy), and embed it in the PR comment.
{
"format_version": "1.2",
"resource_changes": [
{
"address": "aws_s3_bucket.logs",
"type": "aws_s3_bucket",
"change": {
"actions": ["create"],
"after": { "bucket": "logs-prod-2024" }
}
}
]
}
The comment-on-PR pattern
The production pattern is:
- The plan job runs on the pull_request event, writes the binary plan, converts it to JSON, and posts the JSON summary as a comment on the PR.
- Subsequent plan runs (after the contributor pushes a fix) update the existing comment rather than creating new ones, so the PR has a single, evolving thread of plan output.
- The reviewer reads the code and the plan. The plan is the review artefact; the code is the implementation.
The mechanics of posting the comment are platform-specific. On GitHub, the dflook/terraform-github-actions collection provides first-class actions for both the plan and the comment. On GitLab, the gitlab.terraform-plan job template provides the same. On self-hosted runners, the pattern is a small script that calls the platform’s API directly. The shape is the same in all cases.
Why the contract matters
The review is the trust mechanism. The reviewer is approving this specific list of changes, against this specific state, with this specific configuration. When the apply step re-plans, three of those inputs may have moved:
- The state may have moved because another apply landed first.
- The configuration is unchanged (the merge has happened) but the code reviewer has not seen the post-merge plan.
- The cloud may have drifted because a resource was changed out of band.
The only way to preserve the review contract is to apply the binary plan that was reviewed. If the plan has changed, the right response is a new review, not an unannounced apply.
terraform plan -input=false -lock-timeout=300s -out=tfplan
terraform show -json tfplan | jq '.resource_changes[] | {address, actions: .change.actions}'
The first command produces the plan; the second extracts the per-resource summary that becomes the PR comment. Together they form the review artefact that the apply step consumes.
Production discipline
- The plan that is reviewed is the plan that is applied. No re-planning between review and apply.
- PR comments are updated, not duplicated. A PR with ten plan comments is a PR no one can review; a PR with one updated comment is a PR with a single review thread.
- Plan output that contains sensitive values is redacted before posting. A database password in plan output, posted as a PR comment, is a credential leak. Terraform supports
-replaceand resource-target flags to keep plan output narrow, and thesensitive = trueargument keeps specific values out of the output. - The plan artifact is stored, not deleted. The binary plan file is the audit record of what was reviewed. Storing it with the CI run metadata makes the audit reconstructable.
- Apply jobs use
-lock-timeout=300sor longer. The default zero timeout is a CI failure waiting to happen.
Cross-course references
- This course, Part XLIX (InfrastructureCI) - lesson
git-cicd-gitops-xlix-06-plan-and-reviewis the general framing of plan as the review artefact; this lesson is the detailed mechanism. - Terraform for Production Sysadmins - Part IX (State) covers the state serial numbers that the binary plan is bound to.
- Terraform for Production Sysadmins - Part XII (StateLocking) covers the lock that the
-lock-timeoutflag contends for. - This course, Part XLIV (Artifacts) - lesson
git-cicd-gitops-xliv-03-terraform-plans-as-artifactsis the broader framing of the plan-as-artifact pattern.
Quiz
Knowledge check · 4 questions
Q1. A team reviews a plan on a pull request. Between the review and the apply, another team applies an unrelated change to the same state. What happens if the apply job runs `terraform plan && terraform apply`?
Q2. Saving a plan as a binary with -out=tfplan and applying that file with `terraform apply tfplan` guarantees that the reviewed plan is the plan that is applied.
Q3. Name the three flags in the canonical CI invocation of terraform plan, and explain why each one matters for review.
Q4. Diagnose a case where the plan-as-artifact contract was broken, and propose the remediation.
A team configures a plan job that runs `terraform plan -out=tfplan` and posts a PR comment, and an apply job that runs `terraform plan && terraform apply`. Over a six-month period, two production incidents occur in which the resources destroyed did not match the resources described in the PR comment. The audit trail shows the PR was approved and the apply succeeded, but the destroyed resource was different.
Passing score: 75%. Answers are checked in this browser.