Git, CI/CD & GitOpsXLIV · ArtifactsTerraformPlans
Terraform plans as artifacts — the plan file as a review surface, plans across jobs
What you'll learn
- Upload a terraform plan file as an artifact with actions/upload-artifact@v4
- Download the plan in the apply job with actions/download-artifact@v4 and apply it with terraform apply
- Surface the plan to reviewers in the PR using the GitHub Actions UI
- Recognise why the plan artifact must be the binary plan (not a regenerated text plan) for the audit trail to hold
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
A Terraform plan is the most consequential artifact a CI pipeline produces. The plan is the contract between the PR and the production state change: it is the set of resource modifications the merge will apply, signed by the commit that proposed them, reviewed by the engineers who must approve them. The artifact is the mechanism that holds the contract: the plan file is uploaded in the plan job, downloaded in the apply job, and inspected in the PR by humans. Lose the artifact and the pipeline applies a plan that was never reviewed.
The plan as a binary artifact
Terraform produces a binary plan file when invoked with
-out=FILE. The binary format is the canonical form: Terraform
hashes the file and verifies the hash on apply. The text format
produced by terraform show is a human-readable render of the
binary plan, but the binary is the contract:
jobs:
plan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.7.5
- name: Terraform init
run: terraform init -backend-config=env-config.hcl
- name: Terraform plan
run: terraform plan -out=tfplan -input=false
- name: Upload plan
uses: actions/upload-artifact@v4
with:
name: terraform-plan
path: tfplan
retention-days: 90
- name: Render plan as text
run: terraform show -no-color tfplan > tfplan.txt
- name: Upload plan text
uses: actions/upload-artifact@v4
with:
name: terraform-plan-text
path: tfplan.txt
retention-days: 90
The plan job uploads two artifacts: the binary tfplan (the
contract) and the text tfplan.txt (the review surface). The
PR UI renders the text for reviewers; the apply job downloads
the binary and applies it.
The apply job downloads the binary
The apply job must apply the binary plan that was reviewed, not a regenerated plan. Regenerating the plan under a different checkout, a different state read, or a different time produces a different plan; the apply executes a different contract than the one that was reviewed:
apply:
needs: plan
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.7.5
- name: Download plan
uses: actions/download-artifact@v4
with:
name: terraform-plan
path: ./plan
- name: Terraform init
run: terraform init -backend-config=env-config.hcl
- name: Terraform apply
run: terraform apply -input=false ./plan/tfplan
The apply step reads the binary plan, verifies the hash, and
executes the changes. The audit trail is: the PR was reviewed
against tfplan.txt; the apply executed tfplan; the two are
bound by the artifact upload and download.
The plan as a review surface
The PR UI surfaces the artifact to reviewers. The Actions tab
shows the workflow run; the run shows the plan job; the plan
job’s artifacts list shows terraform-plan and
terraform-plan-text. A reviewer clicks the text artifact and
sees the rendered plan. The review is the act of reading the
plan and approving the PR; the approval is the human signature
on the contract.
flowchart LR
A["PR opened with .tf changes"] --> B["Plan job"]
B --> C["terraform plan -out=tfplan"]
C --> D["upload-artifact terraform-plan"]
C --> E["terraform show > tfplan.txt"]
E --> F["upload-artifact terraform-plan-text"]
D --> G["Artifact store"]
F --> G
G --> H["Apply job downloads terraform-plan"]
G --> I["PR reviewer downloads terraform-plan-text"]
H --> J["terraform apply -input=false"]
I --> K["Reviewer reads plan, approves PR"]
K --> L["Merge triggers apply"]
L --> H
The chain is: commit → plan → binary plan → artifact → applied plan. The reviewer sees the text; the apply sees the binary; both are the same plan, bound by the run ID and the commit SHA.
Production discipline
- Upload the binary plan (
-out=tfplan) and the text plan (terraform show) as separate artifacts. The binary is the contract; the text is the review surface. - Apply the downloaded binary plan. Never regenerate the plan in the apply job.
- Run the plan job on the PR trigger. The reviewer must see the plan before approval.
- Set
retention-daysto the audit window. A typical value is 90 days; compliance-bound teams may need longer. - Gate the apply on a protected environment. The apply job must require approval to run; the artifact is the evidence, the environment approval is the human gate.
Cross-course references
- Git, CI/CD & GitOps — Part XLIV-01 (What an artifact is) covers the artifact model and the audit invariant.
- Terraform for Production Sysadmins — Parts IX-XII (State) cover Terraform state, the artifact store for plans, and the audit invariant.
- Git, CI/CD & GitOps — Part XXXVIII-06 (Artifacts, caches, and outputs) covers the three-mechanism model.
Quiz
Knowledge check · 4 questions
Q1. A Terraform apply job re-runs `terraform plan` and then applies the new plan. What is the failure mode?
Q2. A pipeline that uploads the text plan (`terraform show`) as an artifact but not the binary plan (`-out=tfplan`) is not sufficient for the apply job to apply the reviewed plan.
Q3. Explain why the binary plan and the text plan are uploaded as two separate artifacts.
Q4. Diagnose why a Terraform apply ran changes that the reviewer did not approve, and prescribe the fix.
Team T's pipeline runs the plan job on the `pull_request` trigger and uploads the binary plan as an artifact. The apply job runs on `push` to `main` (after merge). The apply job re-checks-out the source, re-runs `terraform plan`, and applies the new plan. A reviewer approves a PR that adds an S3 bucket. After merge, the apply job creates the S3 bucket, plus an IAM role that the reviewer did not see in the plan. The IAM role is in production. The audit trail shows the PR was approved, but the apply executed a different plan than the one reviewed.
Passing score: 75%. Answers are checked in this browser.