linuxlab.io
Tutorials▾
  • Linux & networking
    File system, processes, TCP/IP, BGP and OSPF
    →
  • Terraform & IaC
    HCL, state, plan/apply on a LocalStack sandbox
    →
  • Git & GitHub
    Object model, plumbing, branching, GitHub Actions
    →
All tutorials →
PricingAboutSign inCreate account
/
Intro
Lessons
Footer
linuxlab-TutorialsPricingAboutPrivacy & cookies
Copyright © 2026 LinuxLab. All rights reserved.
linuxlab.io
Tutorials▾
  • Linux & networking
    File system, processes, TCP/IP, BGP and OSPF
    →
  • Terraform & IaC
    HCL, state, plan/apply on a LocalStack sandbox
    →
  • Git & GitHub
    Object model, plumbing, branching, GitHub Actions
    →
All tutorials →
PricingAboutSign inCreate account
/
  • Введение
  • Уроки
  • How it works
  • База знаний
  • Шпаргалка
  • Capstone
  • Собеседование
home/terraform/lessons/tf-production-07-plan-as-artifact

lesson ── terraform-production ── ~14 мин ── 5 шагов

Plan as an artifact, between PR and apply

In a production pipeline, plan and apply are separate jobs. The plan job runs plan -out=plan.tfplan and uploads it as an artifact; the apply job downloads that exact file and applies it. The goal is for apply to roll out the same plan the reviewer saw, not a fresh one. That guarantee holds as long as the artifact lives in protected storage and the reviewer ran show against this very file. In this lesson you will emulate the pipeline with shell scripting and learn what -input=false and detailed-exitcode do.

▶ интерактивный sandbox

Поднимется пара контейнеров: terraform 1.9 и localstack 3.8 в одной сети. В браузере откроется терминал, можно сразу terraform init. Каждый шаг проверяется автоматически. TTL 45 минут, без регистрации.

запустить sandbox →

stack ── terraform · localstack · 1 GB RAM · самоуничтожается через 45 мин простоя

Шаги

  1. 01

    HCL and the first plan-to-file

    bash
    cd /home/student/tf-artifact
    cat > main.tf <<'EOF'
    resource "aws_s3_bucket" "artifact_demo" {
      bucket = "linuxlab-artifact-demo"
      tags = {
        ManagedBy = "terraform"
      }
    }
    output "name" {
      value = aws_s3_bucket.artifact_demo.bucket
    }
    EOF
    terraform init -input=false -no-color > /dev/null
    terraform plan \
      -input=false \
      -no-color \
      -lock-timeout=2m \
      -out=plan.tfplan
    ls -la plan.tfplan

    The plan.tfplan file is binary and is not edited by hand. It is the serialized record of "what Terraform wants to do".

    ✓ The plan file is created. That is the artifact.

  2. 02

    Turning plan.tfplan into txt and json

    For the reviewer, human-readable:

    bash
    terraform show -no-color plan.tfplan > plan.txt
    head -30 plan.txt

    This is what you paste into the PR comment, the +/~/- diff.

    For the policy gate, machine-readable:

    bash
    terraform show -json plan.tfplan > plan.json
    jq '.resource_changes[].address' plan.json

    The JSON holds a resource_changes structure where OPA and Checkov look for violations. One plan.tfplan, two representations.

    ✓ Plan in three formats: binary for apply, txt for review, json for policy.

  3. 03

    detailed-exitcode for a conditional pipeline

    bash
    set +e
    terraform plan -detailed-exitcode -no-color -out=plan.tfplan
    code=$?
    set -e
    echo "exit: $code"

    We have not applied yet, so it should be 2 (there are changes). Apply it:

    bash
    terraform apply -input=false -no-color plan.tfplan

    Now run detailed-exitcode again:

    bash
    set +e
    terraform plan -detailed-exitcode -no-color -out=plan.tfplan
    code=$?
    set -e
    echo "exit: $code"

    It should be 0, no changes.

    The CI pattern:

    • exit 0 → skip apply.
    • exit 2 → upload-artifact + apply job waits for approve.
    • exit 1 → fail the pipeline, go dig.

    ✓ detailed-exitcode gives three states. CI branches on them.

  4. 04

    Simulating the pipeline: plan-job → apply-job

    Let's make a change, run plan again, and hand the plan to the "apply job" (another shell, as if it were a different VM):

    bash
    sed -i 's|"linuxlab-artifact-demo"|"linuxlab-artifact-v2"|' main.tf
    cat main.tf
    # plan-job
    terraform plan \
      -input=false \
      -no-color \
      -out=plan.tfplan
    # simulate upload-artifact / download-artifact
    mkdir -p /tmp/ci-artifacts/
    cp plan.tfplan /tmp/ci-artifacts/
    # apply-job, imagine this is another machine in the pipeline
    cp /tmp/ci-artifacts/plan.tfplan ./plan.tfplan
    terraform apply -input=false -no-color plan.tfplan

    Apply did exactly what was in the plan. In real GitHub Actions this step is actions/upload-artifact + actions/download-artifact.

    ✓ The pipeline loop works. The bucket was renamed through the saved plan.

    The same thing on OpenTofu

    OpenTofu keeps the CLI and state compatible with Terraform for the commands in this step: migration usually goes through mv .terraform .terraform.bak; tofu init -upgrade. On a first switch, though, back up the state and do a run on a feature branch, the differences cluster in the newer features (variables in backend, state encryption, OCI registry-backed modules). See tf-opentofu-parity for the full matrix.

    • → OpenTofu parity
  5. 05

    Stale plan, what happens when state moves ahead

    Apply on a stale plan must fail. Let's set up the stale scenario:

    bash
    # plan once
    terraform plan -input=false -out=stale.tfplan
    # apply through a different plan (different changes)
    sed -i 's|"linuxlab-artifact-v2"|"linuxlab-artifact-v3"|' main.tf
    terraform plan -input=false -out=current.tfplan
    terraform apply -input=false current.tfplan
    # now try to apply the OLD stale.tfplan
    set +e
    terraform apply -input=false stale.tfplan 2>&1 | head -20
    code=$?
    set -e
    echo "exit: $code"

    Apply failed because the stale plan references state that is now out of date. Terraform protects against a race condition between plan and apply.

    This matters: it catches the case where state moved ahead between "the reviewer approved" and "CI applied". If someone pushed something to main and apply ran, your stale plan is no longer relevant, so apply fails.

    ✓ The stale plan protects against a race condition. It is a safety net.

    Plan as a secret

    plan.tfplan holds:

    • All sensitive variable values and outputs.
    • The contents of resources (including passwords on aws_db_instance).
    • Computed attributes the provider resolved during refresh (private endpoints, IP addresses, ARNs).

    That means:

    1. Minimum retention. In GitHub Actions actions/upload-artifact retention-days: 1 is enough for a plan-then-apply pipeline.

    2. Do not publish it. A public repository means a public artifact, which means a secret leak.

    3. terraform show plan.tfplan unfolds everything. Anyone with access to the artifact effectively sees the state sections that are sensitive in HCL.

    4. terraform show -json is worse, a machine-readable structure with no redaction.

    For a prod pipeline: plan files are encrypted (sops, age) or use the CI's artifact-encryption feature. See tf-secrets-in-state.

    • → Plan-as-artifact in detail
    • → Secrets in plan and state

Что ты узнал

terraform plan -input=false -out=plan.tfplan, a binary plan. terraform show -json plan.tfplan > plan.json, machine-readable. terraform apply plan.tfplan, applies exactly what is in the file. -detailed-exitcode 0=clean, 1=err, 2=changes, a gate for CI.

команды

  • terraform plan -input=false -no-color -out=plan.tfplanstandard automation mode, plan to a file.
  • terraform show -no-color plan.tfplan > plan.txthuman-readable for review.
  • terraform show -json plan.tfplan > plan.jsonfor OPA/Checkov/terraform-compliance.
  • terraform apply -input=false -no-color plan.tfplanapplies the saved plan. No fresh plan.

концепции

  • · plan.tfplan is binary, do not read it by hand
  • · stale plan: state changed between plan and apply, apply will fail
  • · plan.tfplan holds sensitive data, treat it like a secret

← предыдущий

state mv, state rm: operations on state

следующий →

Capstone, VPC + ALB + ECS Fargate + Lambda

Footer
linuxlab-
Copyright © 2026 LinuxLab. All rights reserved.
Tutorials
Pricing
About
Privacy & cookies