Skip to main content
RunBook Academy

Git, CI/CD & GitOpsCIX · Terraform Delivery PipelinePlanArtifact

Plan as an artifact — the review surface

Advanced⏱ ~27 mingitterraformjq

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

Not yet marked complete on this device.

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=tfplan writes 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=false disables interactive prompts. A CI job that hangs waiting for input is a CI job that never completes.
  • -lock-timeout=300s waits 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:

  1. The plan job produces tfplan.
  2. The plan file is hashed and stored as a pipeline artefact.
  3. The apply job downloads the artefact.
  4. 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 with address, type, name, change.actions (["no-op"], ["create"], ["update"], ["delete"], ["replace"]), and change.before / change.after blocks.

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

  1. 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.
  2. The plan file is uploaded as an artefact keyed by commit SHA. The apply downloads the same artefact.
  3. terraform show -json tfplan | jq . is the form the policy gate and the PR annotator consume. The textual terraform show tfplan form is for human reading; the JSON form is for everything else.
  4. 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”.
  5. 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-timeout flag 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-06 covers the intermediate-level framing of plan-as-artefact.

Quiz

Knowledge check · 4 questions

  1. Q1. Why does `terraform plan -out=tfplan -input=false -lock-timeout=300s` include `-lock-timeout=300s`?

  2. 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.

  3. Q3. What does `terraform show -json tfplan` produce, and what does the next pipeline stage typically consume it for?

  4. 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.