Infrastructure as Code, Terraform Plans, Policy Checks, and Deployment Workflows: Core Concepts and Mental Model
Model IaC delivery as exact source, provider-lock, backend/workspace, plan, policy, approval, apply, state, and verification transitions.
Learning objectives
- Explain why an IaC plan is evidence about one source, provider set and state snapshot rather than a generic approval document.
- Separate repository revision, provider lock, backend/workspace, plan file, policy result, environment approval and applied state.
- Inspect IaC state read-only before any plan or apply mutation.
- Explain why a saved plan can be sensitive and why its digest does not replace state/backend identity.
- Trace a plan-to-apply lifecycle that preserves first-failure and governance evidence.
1. The problem: infrastructure code is only half of the identity
Earlier chapters treated source SHAs, build artifacts, attestations
and deployment targets as separate evidence. Infrastructure as code
adds another stateful actor: the IaC backend. The same
main.tf can produce a different plan against a
different workspace, state snapshot, provider version or variable
set. A review that says “the Terraform looks fine” is therefore not
enough to authorize an apply.
The operational goal is narrower than teaching Terraform itself. GitHub Actions must prove which repository revision and provider selections produced the plan, which backend/workspace and state snapshot the plan observed, which policy checks accepted it, and whether the later apply is still acting on the same authorized context.
2. Mental model: source + state context → reviewed change → guarded mutation
Read this model from left to right. GitHub chooses an exact workflow/source revision. Terraform initializes the configured backend and locked provider versions, then formatting and validation establish structural quality. Planning compares desired configuration with current state and, where applicable, refreshed remote objects. Policy checks interpret the proposed change. A guarded apply may proceed only after the plan, source and target context still match.
flowchart TD A[Exact source SHA] --> B[Terraform / OpenTofu version] B --> C[Provider lock + backend/workspace identity] C --> D[fmt + validate] D --> E[Saved plan + digest] E --> F[Policy/security checks] F --> G[Reviewed plan evidence] G --> H[Protected apply job] H --> I[State / infrastructure mutation] I --> J[Post-apply verification] I --> K[Failure / partial state evidence]
The arrows matter because each transition can fail independently. A green policy check does not prove the apply is authorized; an approved environment does not prove the saved plan still matches current state; and a successful Terraform exit does not automatically prove the external service is healthy.
3. Define the state before changing it
| Layer | Record | Why it matters |
|---|---|---|
| Event/revision |
event, ref, GITHUB_SHA, workflow path/revision,
run ID/attempt
|
Binds automation to one repository revision. |
| Toolchain | Terraform/OpenTofu exact version; setup action SHA | Prevents silent CLI/runtime drift. |
| Dependencies |
.terraform.lock.hcl, provider
source/version/checksums
|
Binds provider selection; the lock file is source evidence, not backend state. |
| Backend/workspace | backend type/address plus workspace/environment identity | Names the state target against which changes are calculated. |
| Plan | binary plan, sanitized human summary, SHA-256, source/state metadata | Represents one proposed transition; it is not a portable policy document. |
| Policy | rule set/version, input digest, pass/fail reasons | Explains why the proposed change was accepted or denied. |
| Authorization | GitHub environment/protection result and optional OIDC claims | Controls who/what may cross into mutation authority. |
| Apply/state | apply result, state serial/lineage or remote revision, changed object IDs | Proves what state actually changed. |
| External verification | provider/resource health or local fixture file content | Proves target outcome independently of workflow color. |
4. Read-only inspection comes before plan or apply
Start with commands that cannot mutate the target. Confirm the source SHA and toolchain, inspect the lock file, identify the backend/workspace, and use state-list/show only when a backend is already initialized and the operator is authorized to read it. In a production cloud environment, even a read operation may require credentials; do not widen privileges merely to make diagnostics convenient.
set -euo pipefail
printf 'run=%s attempt=%s sha=%s ref=%s
' "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT" "$GITHUB_SHA" "$GITHUB_REF"
terraform version
sha256sum .terraform.lock.hcl
terraform workspace show
terraform state list || true
Do not print backend credentials, cloud tokens, raw OIDC JWTs, Terraform state or plan JSON merely for debugging. State and saved-plan representations can contain sensitive values even when normal CLI output marks values as sensitive.
5. A saved plan is bound evidence, not a timeless artifact
A saved plan records the actions Terraform intends to take after evaluating a particular configuration and state context. Applying that file tells Terraform to execute the recorded plan rather than silently calculate a fresh one. This is valuable for review because the mutation can be tied to the reviewed plan bytes.
But the plan must remain bound to its context. If state changes after planning, Terraform can reject the old plan as stale. Plan files are opaque implementation artifacts, may include sensitive data, and should not be treated as a stable cross-version interchange format. Record the CLI version, provider lock, backend/workspace identity, source SHA and plan digest alongside them.
6. Provider lock and backend state are different controls
The dependency lock file records provider versions and checksums. Commit it with IaC source and verify it before planning; this controls executable provider dependency drift. The backend stores infrastructure state and may provide locking. A committed lock file cannot prevent two workflows from racing to change the same backend, and a backend lock cannot stop a workflow from using an unexpected provider version if dependency selection is not controlled.
7. Policy checks evaluate proposed change, not operator intent
A useful policy stage consumes machine-readable plan information and produces a deterministic decision such as “no deletes,” “only approved resource classes,” or “estimated count below a threshold.” The policy input itself must be traceable to the saved plan digest. In the mandatory lab the plan contains only synthetic local-file data, so a small Python policy checker is sufficient; production systems may use OPA, Sentinel, Conftest or cloud-specific policy services.
Policy is not an approval substitute. A policy can say the change satisfies machine rules while the environment gate determines whether this workflow identity may mutate the target now. Conversely, a human approval must not override a deterministic policy failure without an explicit exception process and audit record.
8. Plan jobs and apply jobs should not inherit the same authority
A pull-request plan usually needs source read access plus whatever read-only backend/provider identity is required to calculate drift. The apply job is a different trust boundary: it should run only from an authorized base-repository revision, behind the intended environment, with narrowly scoped OIDC or credentials. Do not give PR code production credentials simply because it runs Terraform.
For the free lab there is no cloud credential at all. The apply changes only a runner-local file through the HashiCorp local provider. This lets learners practice plan identity, provider locking, evidence transfer, stale-plan rejection and environment gating without creating cloud resources.
9. The evidence chain must survive reruns
| Evidence | Preserve before cleanup/rerun | Do not confuse with |
|---|---|---|
| Run identity | run ID, attempt, source/workflow SHA | plan digest |
| Dependency identity | setup-action SHA, Terraform version, lock-file hash | backend lock |
| Target identity | backend/workspace/state lineage/serial where available | repository branch |
| Plan identity | plan SHA-256 + summary/policy input hash | human approval |
| Policy result | rule version + reasons | Terraform validation |
| Authorization | environment approval / OIDC subject | provider permission |
| Mutation | apply result + state/object change | health |
| Recovery | failure/partial state captured before corrective action | blind rerun |
10. Common wrong model: “plan in PR, terraform apply on main”
Those two lines sound safe but omit the binding. Was main the same source revision that produced the plan? Did the workspace/backend change? Did a provider upgrade occur? Was a fresh plan silently generated during apply? Did state advance because another run applied first? Production automation should make those questions answerable from retained evidence rather than team memory.
11. Lesson summary
IaC automation is trustworthy when the proposed transition and the mutation are bound to exact source, dependencies and state context. Plan digest, policy decision, approval, credentials and apply result are separate evidence fields.
Knowledge check
Why is a Terraform plan digest alone insufficient to authorize apply?
Because the plan must also be tied to source, Terraform/provider versions and the backend/workspace/state context it was created against.
What does .terraform.lock.hcl control?
Provider dependency selection and checksums. It does not lock remote infrastructure state.
Why can a stale saved plan be a desirable failure?
It proves the state context changed after review, so applying the previously reviewed transition would no longer be trustworthy.
Should a pull-request plan job receive production write credentials?
No. Plan and apply are different trust boundaries; PR code should not gain mutation authority merely to calculate a plan.
Why is plan JSON handled carefully?
Machine-readable plan data can contain sensitive values. Generate and retain only what the evidence/policy process requires.
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.
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.