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.
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
- Open a pull request that changes only the fixture. Expect plan/policy evidence but no apply job.
-
After review, manually dispatch with
apply=trueandstale_before_apply=false. Expect the exact saved plan to createout/marker.txtin the ephemeral runner and verify its content. -
Dispatch again with
apply=trueandstale_before_apply=true. Before the saved plan is applied, the lab intentionally performs an intervening local-state change. - 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.
Knowledge check
Why does the plan job use
-lockfile=readonly?
To ensure CI consumes the committed provider selection rather than silently changing dependency lock state.
Why upload both the binary plan and a digest?
The binary is what apply consumes; the digest lets the apply/evidence stages prove they received the same bytes.
The stale-plan run fails. Is rerunning apply against the same old plan the right repair?
No. First inspect why state changed. If legitimate, create and review a new plan bound to the current state.
Why is id-token: write absent from the mandatory
lab?
There is no cloud federation call. Least privilege means not requesting capabilities the workflow does not use.
What does the local file verify independently?
It proves the simulated target mutation actually produced the expected content, separately from the Terraform job conclusion.
Official references and version notes
- Terraform CLI plan — Saved plans, planning modes, detailed exit codes and the security warning for plan files.
- Terraform CLI apply — Applying a saved plan and automation semantics.
- Terraform dependency lock file — Provider selections/checksums and why the lock file belongs with source.
- Terraform state locking — State-lock behavior and the operational risk of force-unlock.
- Terraform local backend — Local state backend used only by the disposable no-cloud lab.
- HashiCorp setup-terraform — Official setup action; current v4 line uses Node 24.
- GitHub environments — Approval/protection boundary for a guarded apply job.
- GitHub OIDC overview — Preferred short-lived cloud federation boundary for optional real-provider adapters.
- Workflow artifacts — Plan/evidence transfer is GitHub artifact state, not infrastructure state.
- OpenTofu documentation — Open-source alternative CLI with a similar plan/apply operating boundary; verify syntax/version separately before substitution.
- actions/checkout v7.0.1 — Pinned source identity action.
- actions/upload-artifact v7.0.1 — Pinned one-day plan/evidence transfer.
- actions/download-artifact v8.0.1 — Pinned retrieval of the reviewed plan.
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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this
address.