Skip to main content
RunBook Academy

TerraformXXII · CI/CD for Production TerraformCI/CD plan artifact

Plan Artifacts as the Audit Trail

Intermediate⏱ ~12 minbashgithub-actionsterraform

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

Not yet marked complete on this device.

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 GitHub environment gate, 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:

  1. 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.
  2. 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.
  3. 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:

  1. 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.
  2. A manual state edit. terraform state mv or terraform state rm from a terminal. The state file changed; the saved plan file is stale.
  3. A drift remediation. terraform apply -refresh-only runs 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

  1. 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 binary tfplan file, not just the JSON.

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

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

  4. 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 init per stage, against the same backend.

  5. Apply used -auto-approve instead 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 to terraform apply, and to remove -auto-approve from the apply command.

  6. 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-color on 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

  1. Q1. Why does a CI apply stage pass the saved plan file as a positional argument instead of using `-auto-approve`?

  2. Q2. What does `-input=false` do in a CI plan command?

  3. Q3. A saved plan file holds the resolved value of a `sensitive = true` variable, so the artifact has to be protected like a credential.

  4. Q4. What is the difference between the binary plan file and the JSON plan output?

  5. Q5. Which of the following must be configured for a CI-safe plan command? (Select all that apply.)

  6. Q6. A plan file's state hash differs from the apply-time state. What does Terraform do?

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