Git, CI/CD & GitOpsCIX · Terraform Delivery PipelinePlanArtifact
Plan as an artifact — the review surface
What you'll learn
- Run terraform plan -out=tfplan -input=false -lock-timeout=300s and explain each flag
- Run terraform show -json tfplan | jq . to produce a machine-readable plan for policy gates
- Identify the plan file as the artefact reviewed, hashed, and stored by the pipeline
- Wire the plan output to the PR as a comment and to OPA/Sentinel as a JSON input
Prerequisites
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
The plan stage is the centrepiece of the Terraform pipeline. Everything before it (fmt, validate, tflint, tfsec, checkov) is preparation; everything after it (policy gate, human approval, apply, drift detection) consumes what it produces. The plan file is not a side effect; it is the deliverable. Treating it as a log line to be printed and discarded is the architectural mistake this lesson is designed to correct.
The plan command
The single most important command in the Terraform pipeline is the plan with -out:
terraform plan -out=tfplan -input=false -lock-timeout=300s
Three flags carry the weight:
-out=tfplanwrites the plan to a binary file on disk. The apply step then consumes that exact file rather than re-deriving intent from live state.-input=falsedisables interactive prompts. A CI job that hangs waiting for input is a CI job that never completes.-lock-timeout=300swaits up to five minutes to acquire the state lock. A lock held by another job is a lock the pipeline waits for; a lock held for longer is an alert condition.
The output of the command is the textual plan (printed to stdout) plus the binary tfplan file (written to the working directory). Both are useful; the textual form is for the PR comment, the binary form is for the apply.
flowchart LR
A["Configuration + state"] --> B["terraform plan -out=tfplan -input=false -lock-timeout=300s"]
B --> C["tfplan binary file"]
B --> D["Textual plan on stdout"]
C --> E["Apply stage consumes"]
C --> F["terraform show -json tfplan"]
F --> G["Policy gate input"]
D --> H["PR comment"]
Why -out is non-negotiable
Without -out, the plan is recomputed at apply time. The apply reads live state, queries live providers, and re-derives the change set. Anything that changed between the plan job and the apply job — a resource modified out-of-band, a state lock released, a provider returning different data — silently changes what the apply does.
With -out, the apply is a mechanical execution of the reviewed plan. The chain of custody is:
- The plan job produces
tfplan. - The plan file is hashed and stored as a pipeline artefact.
- The apply job downloads the artefact.
- The apply runs
terraform apply tfplan.
The apply does not re-query state. The apply does not re-evaluate expressions. The apply executes the binary graph. If the binary graph and the live state are out of sync, the apply fails fast with a clear error, and the team knows the chain of custody has been broken.
The JSON plan
The binary tfplan file is not human-readable. To feed it into a policy gate, a Slack poster, or a PR annotator, the plan is converted to JSON:
terraform show -json tfplan | jq .
The output is a structured document with three top-level fields:
format_version— the JSON plan schema version.terraform_version— the Terraform binary that produced the plan.resource_changes[]— an array of per-resource change records, each withaddress,type,name,change.actions(["no-op"],["create"],["update"],["delete"],["replace"]), andchange.before/change.afterblocks.
A policy gate reads resource_changes[], applies OPA or Sentinel rules, and either accepts or rejects the plan. A PR annotator reads resource_changes[] and posts a Markdown summary to the PR. A drift detector compares resource_changes[] between two plan runs and posts the diff.
Storing the plan artefact
The pipeline must store the plan file as a build artefact keyed by commit SHA. A GitHub Actions example:
- name: terraform plan
run: terraform plan -out=tfplan -input=false -lock-timeout=300s
- name: hash plan
run: sha256sum tfplan | tee tfplan.sha256
- name: upload plan
uses: actions/upload-artifact@v4
with:
name: tfplan-${ github.sha }
path: |
tfplan
tfplan.sha256
retention-days: 30
The retention policy is a deliberate choice: long enough that the apply can be retried if a transient failure occurs, short enough that the artefact store does not accumulate plans indefinitely.
Wiring the plan to the PR
The textual plan output belongs on the PR as a comment. A typical pipeline runs terraform show -no-color tfplan and posts the result via the GitHub API:
terraform show -no-color tfplan > tfplan.txt
gh pr comment "$PR_NUMBER" --body-file tfplan.txt
The reviewer sees the exact change set as text: + resource "aws_s3_bucket" "logs" and - resource "aws_security_group_rule" "old". A reviewer who approves the PR is approving that text.
Production discipline
- Every plan uses
-out=tfplan -input=false -lock-timeout=300s. A pipeline that omits any of these flags is a pipeline that has not been reviewed for production use. - The plan file is uploaded as an artefact keyed by commit SHA. The apply downloads the same artefact.
terraform show -json tfplan | jq .is the form the policy gate and the PR annotator consume. The textualterraform show tfplanform is for human reading; the JSON form is for everything else.- The lock timeout is bounded. A pipeline that hangs waiting for a lock is a pipeline that holds credentials while waiting. The timeout converts “hang” into “alert”.
- The PR comment is the textual plan, posted automatically. A reviewer who reads the plan in the CI logs rather than on the PR is a reviewer who has been trained to skip the audit trail.
Cross-course references
- Terraform for Production Sysadmins - Parts IX-XII (State) cover the locking and backends the
-lock-timeoutflag interacts with. - This course, Part LIII (PolicyAsCode) - the OPA and Sentinel gates that consume the JSON plan.
- This course, Part LVII (GitOpsControllers) - the Argo CD and Flux controllers that consume the same JSON form for reconciliation.
- This course, Part L (TerraformCI) - lesson
git-cicd-gitops-l-06covers the intermediate-level framing of plan-as-artefact.
Quiz
Knowledge check · 4 questions
Q1. Why does `terraform plan -out=tfplan -input=false -lock-timeout=300s` include `-lock-timeout=300s`?
Q2. A pipeline that runs `terraform plan` for review and then runs `terraform apply -auto-approve` in a separate job treats the plan as an artefact.
Q3. What does `terraform show -json tfplan` produce, and what does the next pipeline stage typically consume it for?
Q4. Diagnose a pipeline that lost its chain of custody between plan and apply, and prescribe the structural correction.
A team's pipeline runs `terraform plan` and prints the output for the PR reviewer. After approval, a separate job runs `terraform apply -auto-approve`. State is locked during both jobs. Six weeks in, an engineer notices resources in production that were never shown in any PR plan. The state lock has been functioning correctly; the chain of custody has not.
Passing score: 75%. Answers are checked in this browser.