Chapter 27Lesson 02~235 minutes

Infrastructure as Code, Terraform Plans, Policy Checks, and Deployment Workflows: Guided Hands-On Workflow

Build a no-cloud Terraform plan→policy→evidence→guarded local apply workflow and prove that stale plan/state identity is rejected.

No-cloud labSaved planPolicy checkArtifactsStale plan

Learning objectives

  • Create a disposable Terraform fixture with a committed provider lock and no cloud credential.
  • Split formatting/validation, saved planning, policy evaluation, evidence upload and guarded apply into observable states.
  • Bind the apply job to source SHA, lock-file hash and plan digest before mutation.
  • Preserve plan/policy evidence after failure and interpret stale-plan rejection causally.
  • Practice a free local workflow while identifying where cloud backend/OIDC adapters would enter.

1. Disposable scenario: a file is our “infrastructure”

The lab repository contains infra/main.tf. Terraform 1.16.1 and hashicorp/local 2.9.0 manage exactly one file under infra/out/marker.txt. This creates real Terraform state and a real saved plan without a cloud account. The only external download is the Terraform CLI and provider package; no secret or production endpoint is needed.

Before committing the workflow, run terraform init once in infra/ and commit the generated .terraform.lock.hcl. CI then uses -lockfile=readonly so provider selection cannot silently rewrite the lock during the plan/apply run.

2. Create the exact IaC fixture

terraform {
  required_version = "= 1.16.1"
  required_providers {
    local = {
      source  = "hashicorp/local"
      version = "= 2.9.0"
    }
  }
}

variable "marker_text" {
  type    = string
  default = "approved-v1"
}

resource "local_file" "marker" {
  filename = "${path.module}/out/marker.txt"
  content  = var.marker_text
}

output "marker_path" {
  value = local_file.marker.filename
}

The local provider is intentionally version-constrained. In a production repository you would also review the lock-file checksums and provider release provenance. The fixture writes only beneath the repository workspace; cleanup can therefore remove the generated state/output without touching shared infrastructure.

3. Add a minimal policy check that reads the proposed change

import json, pathlib, sys
plan = json.loads(pathlib.Path("plan.json").read_text())
violations = []
for rc in plan.get("resource_changes", []):
    actions = rc.get("change", {}).get("actions", [])
    if "delete" in actions:
        violations.append(f"delete denied: {rc.get('address')}")
    after = rc.get("change", {}).get("after") or {}
    fn = after.get("filename")
    if fn and not fn.replace('\\','/').endswith('/infra/out/marker.txt'):
        violations.append(f"unexpected path: {fn}")
if violations:
    print("POLICY=deny")
    print("\n".join(violations))
    sys.exit(2)
print("POLICY=allow")

This policy is deliberately small so the lesson stays about Actions integration, not policy-language syntax. It denies deletes and any output path outside the bounded fixture. Because plan.json is generated from synthetic data, it is safe enough for this lab artifact. Do not generalize that to real plan JSON: it can expose sensitive values.

4. Plan first; mutation authority enters only in the apply job

name: Disposable IaC plan and apply lab
on:
  pull_request:
    paths: ["infra/**", ".github/workflows/iac-lab.yml"]
  workflow_dispatch:
    inputs:
      apply:
        description: "Apply the reviewed local plan"
        type: boolean
        required: true
        default: false
      stale_before_apply:
        description: "Intentionally change local state before applying saved plan"
        type: boolean
        required: true
        default: false

permissions: {}

jobs:
  plan:
    name: Plan / policy / evidence
    runs-on: ubuntu-24.04
    permissions:
      contents: read
    outputs:
      plan_sha256: ${{ steps.plan_meta.outputs.plan_sha256 }}
      lock_sha256: ${{ steps.plan_meta.outputs.lock_sha256 }}
    defaults:
      run:
        working-directory: infra
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
        with:
          terraform_version: "1.16.1"
          terraform_wrapper: false
      - name: Verify source and locked initialization
        shell: bash
        run: |
          set -euo pipefail
          test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
          terraform version
          terraform fmt -check -diff
          terraform init -input=false -lockfile=readonly
          terraform validate
      - name: Create one saved plan and policy representation
        shell: bash
        run: |
          set -euo pipefail
          terraform plan -input=false -out=tfplan
          terraform show -no-color tfplan > plan.txt
          terraform show -json tfplan > plan.json
          python policy.py | tee policy.txt
      - id: plan_meta
        name: Record evidence identities
        shell: bash
        run: |
          set -euo pipefail
          plan_sha=$(sha256sum tfplan | awk '{print $1}')
          lock_sha=$(sha256sum .terraform.lock.hcl | awk '{print $1}')
          printf 'plan_sha256=%s
lock_sha256=%s
source_sha=%s
run=%s
attempt=%s
'             "$plan_sha" "$lock_sha" "$GITHUB_SHA" "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT" > metadata.env
          echo "plan_sha256=$plan_sha" >> "$GITHUB_OUTPUT"
          echo "lock_sha256=$lock_sha" >> "$GITHUB_OUTPUT"
      - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        if: ${{ always() }}
        with:
          name: iac-plan-${{ github.run_id }}-${{ github.run_attempt }}
          path: |
            infra/tfplan
            infra/plan.txt
            infra/plan.json
            infra/policy.txt
            infra/metadata.env
            infra/.terraform.lock.hcl
          retention-days: 1

  apply:
    name: Guarded local apply
    if: ${{ github.event_name == 'workflow_dispatch' && inputs.apply }}
    needs: plan
    runs-on: ubuntu-24.04
    environment: iac-lab
    concurrency:
      group: iac-lab-local-state
      cancel-in-progress: false
    permissions:
      contents: read
    defaults:
      run:
        working-directory: infra
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
        with:
          terraform_version: "1.16.1"
          terraform_wrapper: false
      - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: iac-plan-${{ github.run_id }}-${{ github.run_attempt }}
          path: infra/evidence
      - name: Re-bind source, lock and plan before mutation
        shell: bash
        run: |
          set -euo pipefail
          test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
          cp evidence/tfplan ./tfplan
          test "$(sha256sum tfplan | awk '{print $1}')" = "${{ needs.plan.outputs.plan_sha256 }}"
          test "$(sha256sum .terraform.lock.hcl | awk '{print $1}')" = "${{ needs.plan.outputs.lock_sha256 }}"
          terraform init -input=false -lockfile=readonly
      - name: Optional controlled state change to prove stale-plan rejection
        if: ${{ inputs.stale_before_apply }}
        shell: bash
        run: |
          set -euo pipefail
          terraform apply -input=false -auto-approve -var 'marker_text=intervening-change'
          terraform state pull > state-before-stale-attempt.json
      - name: Apply exactly the saved reviewed plan
        shell: bash
        run: |
          set -euo pipefail
          terraform apply -input=false tfplan
      - name: Verify target independently
        shell: bash
        run: |
          set -euo pipefail
          test "$(cat out/marker.txt)" = 'approved-v1'
          terraform output -raw marker_path
          terraform state pull | sha256sum

The plan job has only contents: read. It verifies the exact checkout, initializes from the committed lock file, creates one saved plan, derives a human rendering and machine policy input, hashes the plan and lock, and uploads evidence even if a later evidence-producing step fails. The apply job exists only for an explicit manual dispatch with apply=true and references the iac-lab environment.

Notice what the environment does not do: it does not make the plan current, it does not verify the backend, and it does not turn a failure into a safe rollback. The job rechecks source/lock/plan identities before it runs terraform apply tfplan.

5. Run sequence: success, then stale-plan proof

  1. Open a pull request that changes only the fixture. Expect plan/policy evidence but no apply job.
  2. After review, manually dispatch with apply=true and stale_before_apply=false. Expect the exact saved plan to create out/marker.txt in the ephemeral runner and verify its content.
  3. Dispatch again with apply=true and stale_before_apply=true. Before the saved plan is applied, the lab intentionally performs an intervening local-state change.
  4. Preserve the second run ID/attempt and its state snapshot. The saved plan should be rejected because its prior-state context no longer matches the current local state. That rejection is the intended evidence, not a reason to force-unlock or regenerate silently.

6. Expected evidence and what each item proves

Evidence Expected value/use Ownership
source SHA exact GITHUB_SHA equals checkout HEAD Git/repository
Terraform 1.16.1 runner toolchain
provider lock hash of committed .terraform.lock.hcl repository dependency state
saved plan SHA-256 + plan.txt + synthetic plan.json Terraform/GitHub artifact
policy POLICY=allow for the approved path policy stage
environment iac-lab approval/protection or documented simulation GitHub governance
apply saved-plan success or preserved stale-plan error Terraform state transition
target proof file content + state hash/output runner-local external state simulation

7. Security boundary: plan artifacts may be sensitive

A binary plan and terraform show -json can contain sensitive information. This lab uses only the string approved-v1 and retains the artifact for one day. A real organization should minimize plan artifact exposure, choose private retention/access controls deliberately, avoid posting raw plan JSON into public PR comments, and sanitize any human summary used for review.

Never solve a plan-access problem by uploading production state or raw credentials as an artifact. State belongs in the configured backend with its own access and locking controls.

8. Where cloud OIDC would enter without changing the contract

For AWS, Azure or Google Cloud, the plan/apply contract stays the same but backend/provider initialization requires a real target identity. Prefer short-lived OIDC federation and separate read-only planning permissions from mutation permissions. Bind the provider trust to the actual repository/workflow/environment identity and record the returned cloud account/project/subscription before planning or applying.

The free fixture intentionally does not request id-token: write. A workflow that never calls an OIDC-aware provider action does not need that permission.

9. Challenge: choose the failing layer

Suppose the plan SHA matches, the policy passes, and the environment is approved, but terraform apply tfplan says the plan is stale. Which layer should you change? Not permissions or policy. The evidence says the state identity/timing changed after planning. Preserve the first failure, inspect state/backend history, then produce a new reviewed plan if the new state is legitimate.

10. Cleanup and rollback

The GitHub-hosted runner is ephemeral, so the local state and marker file disappear with the job. If you reproduce locally, remove only infra/out/, infra/terraform.tfstate*, infra/tfplan and infra/.terraform/; keep the committed lock file. In a real remote backend, cleanup is an infrastructure change and must use the same guarded workflow rather than deleting state files.

11. Lesson summary

The lab turns plan/apply into an evidence pipeline: source and provider lock define executable intent, the saved plan defines proposed change, policy evaluates that exact plan, the environment controls mutation entry, and state freshness can veto an obsolete approval.

Next lesson

Infrastructure as Code, Terraform Plans, Policy Checks, and Deployment Workflows: Configuration, Design Patterns, and Trade-Offs

Continue with the next lesson to build on the current concepts, evidence, security boundaries, and operational practices.

Knowledge check

Why does the plan job use -lockfile=readonly?

Why upload both the binary plan and a digest?

The stale-plan run fails. Is rerunning apply against the same old plan the right repair?

Why is id-token: write absent from the mandatory lab?

What does the local file verify independently?

Official references and version notes

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.