Module 3: The Iac Pipeline Fmt Validate Plan
2. The HashiCorp/GitHub pattern: `plan` on PR, `apply` on merge
Description
This lesson answers a very concrete question: where exactly does the pattern organizing this entire module come from? It's not a convention invented for this guide — it's, word for word, the pattern HashiCorp's official tutorial for automating Terraform with GitHub Actions documents, verified today against that source. You're going to see what that tutorial says, which parts of its approach this guide uses and which it adapts, and —this lesson's central point— why separating plan (in ci.yml, built in this module) from apply (in apply.yml, built in Module 5) into two distinct files is a security decision, verified with a concrete scenario, not a folder-organization preference.
Connection to the module
Lesson 1 gave you the general idea ("the plan is the review point"). This lesson puts an exact source and a concrete mechanism to it: why two files, not one with a condition. Lesson 3 continues with the how — installing Terraform inside the runner that's going to execute that plan. Lessons 4 through 8 are ci.yml's real construction, the half of the pattern you can build without touching write credentials yet (those arrive in Module 4, and the apply.yml that uses them, in Module 5).
Analogy: two keys for two different doors, not one key with a lock that sometimes opens
Imagine a building with two doors: one leading to the showroom (anyone with an invitation can enter and look) and another leading to the vault (only someone with the right key, at the right moment, can enter). You could, in theory, build a single door with a smart lock that decides, based on who knocks, whether to let you into the showroom or the vault — but that means the vault's security depends on that lock never getting it wrong, never being fooled, never having a bug. Two physically separate doors are simpler to reason about: the showroom door never, under any condition, leads to the vault — not because a lock decides correctly every time, but because there's physically no other path. ci.yml and apply.yml are those two doors: ci.yml listens for pull_request and can only read and calculate (plan); apply.yml listens for push to main and is the only file that runs apply. There's no condition in the middle deciding which one executes — they're two distinct files, triggered by distinct events, and that's what makes the separation reliable.
What HashiCorp's official tutorial says, verified
HashiCorp Developer's "Automate Terraform with GitHub Actions" tutorial —the source cited in this guide's design— builds the pattern with two separate workflows:
- A workflow that runs on every Pull Request, generates a
terraform plan, and publishes thatplanas a comment on the Pull Request itself (usingactions/github-script, which you're going to see closely in lesson 7). - A separate workflow that runs when the change reaches the main branch (push to
main, typically after the merge), and applies the corresponding plan.
An honest clarification, verified by reading the complete tutorial today: the exact implementation HashiCorp shows uses HCP Terraform (HashiCorp's managed platform, with actions like hashicorp/tfc-workflows-github) to execute plan/apply remotely, not the terraform binary running directly inside the runner. This guide doesn't use HCP Terraform —it would add an external account and a dependency that would break the $0 and full-Docker-reproducibility commitment sustaining this entire ecosystem. What this guide takes from the tutorial isn't the line-by-line implementation, it's the pattern: two separate workflows, one that only calculates and shows (triggered by pull_request), another that applies (triggered by push to main), with the plan visible to whoever reviews it before the possibility of applying anything exists. That pattern is implemented here with the terraform/tflocal binary running directly inside act's runner, against LocalStack — exactly like you already ran terraform in terraform-and-iac-guide, only automated now.
The pattern, adapted to this guide
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ ci.yml │ │ apply.yml (Module 5) │
│ on: pull_request │ │ on: push (branches: [main])│
│ │ │ │
│ 1. checkout │ │ 1. checkout │
│ 2. setup-terraform │ │ 2. setup-terraform │
│ 3. terraform fmt -check │ │ 3. terraform init │
│ 4. terraform init │ │ 4. terraform apply │
│ 5. terraform validate │ │ (over the plan already │
│ 6. terraform plan ───────┼────────▶│ reviewed in the PR) │
│ 7. publish the plan │ human │ │
│ (STEP_SUMMARY / │ reviews │ │
│ comment on the PR) │ and │ │
│ │ approves│ │
└─────────────────────────────┘ └─────────────────────────────┘
NEVER writes ONLY runs after
real infrastructure main has changed
ci.yml —what you build in this module— is the showroom door: it reads, calculates, shows. It never has, and never will have, a single write permission over real infrastructure. apply.yml —Module 5— is the vault door: the only one that runs terraform apply, and it only triggers once the code is already on main, meaning it already went through human review and merging.
Why separating into two files is safer than one with a condition
This is the lesson's core, and it's worth reasoning through with a concrete scenario, not abstractly.
The dangerous design (which this guide does not build): a single workflow, terraform.yml, listening for both pull_request and push, with a job that starts like this:
jobs:
terraform:
steps:
- run: terraform plan
- name: Apply only on merge to main
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: terraform apply -auto-approve
At first glance it seems reasonable: "apply only runs if the event is push to main." The problem isn't that the condition is written wrong — it's that the condition lives in the same file that runs on every Pull Request, including Pull Requests that haven't been reviewed yet. If that file ever gets more complex (for example, someone adds a workflow_dispatch with an input controlling whether it applies or not, or a second condition someone misunderstands), the only obstacle between "an unreviewed PR" and "a real apply against production" is conditional logic inside a file that, technically, any Pull Request could try to modify as part of its own proposed change. This isn't a hypothetical scenario: it's exactly the vulnerability family known in the industry as a "pwn request" — a Pull Request that manipulates the very workflow evaluating it to gain permissions it shouldn't have.
This guide's design: ci.yml (this module) never contains the word apply in any run:. apply.yml (Module 5) never listens for pull_request. There's no condition to get right because there's no path, in any file, connecting an unreviewed Pull Request to a real terraform apply. Security doesn't depend on a condition being written correctly today and staying correct forever — it depends on a structural separation that can't be violated by accident.
The saved plan, not a new one (preview of Module 5)
There's a second detail of HashiCorp's pattern worth naming now, even though it's only built in Module 5: when apply.yml runs, it shouldn't generate a new plan and blindly apply it — it should apply that exact same plan a person already reviewed in the Pull Request. Between the moment someone approves a plan and the moment it merges, the infrastructure's real state could have changed (another change merged first, someone modified something by hand) — if apply.yml recalculated the plan from scratch, it could end up applying something different from what the person approved. The mechanism for passing the exact plan from one workflow to another (actions/upload-artifact / download-artifact, with needs: to chain the jobs) is Module 5's content — it's named here because it's the other half of why HashiCorp separates the two workflows: not just for permission security, but also for consistency between what was reviewed and what gets applied.
Common mistakes
Assuming "two workflows" means "double the maintenance work" (perception-based). What happens: someone sees ci.yml and apply.yml as separate files and assumes the same logic has to be maintained twice. Why it happens: the "fewer files is simpler" intuition is reasonable in most code contexts. How to fix it: the two files share very little code in common —ci.yml never applies, apply.yml never runs on an unreviewed proposal— so there's no duplicated logic to keep in sync. The complexity of maintaining two small, single-purpose files is, in practice, lower than that of maintaining one file with conditions that need careful review every time they change.
Thinking HCP Terraform is mandatory to follow this pattern (source-based, clarified above). What happens: someone reads HashiCorp's official tutorial, sees hashicorp/tfc-workflows-github, and assumes that without an HCP Terraform account the "plan on PR / apply on merge" pattern can't be built. How to spot it: if you doubt whether this guide's ci.yml/apply.yml are "the real pattern" because they don't use those specific Actions. How to fix it: the pattern is the separation of workflows by event and the plan as a review artifact — that's independent of whether terraform plan/apply runs against HCP Terraform or directly with the terraform/tflocal binary, as this guide does. HCP Terraform adds remote state management, Sentinel policies, and centralized execution — real capabilities, but outside this guide's $0, fully-local scope.
Confusing "separating into two files" with "separating into two repositories" (scope-based). What happens: someone assumes this lesson's security separation requires ci.yml and apply.yml to live in different repositories. How to fix it: both files live in the same repository, inside .github/workflows/ — the separation that matters is by triggering event (pull_request vs. push to main), not by physical location. A single repository can have dozens of distinct workflows, each with its own on:, without that compromising the separation of responsibilities between them.
Exercises
Exercise 1 — Identify the dangerous design's risk. Looking at the "dangerous design" YAML above (terraform.yml with the if: condition), specifically identify what would have to go wrong for an unreviewed Pull Request to end up applying real infrastructure.
See solution
The condition if: github.event_name == 'push' && github.ref == 'refs/heads/main' would have to fail — for example, if someone rewrites it incorrectly in a later change (an || instead of &&, an incorrect branch comparison), or if the workflow itself gets modified as part of a Pull Request that also tries to exploit that modification in the same run (the "pwn request" scenario named in the lesson). The central point: the entire pipeline's security depends on one conditional line of code never getting it wrong, never being manipulated — a single point of failure, instead of a structural separation that doesn't depend on anything being "written correctly."
Exercise 2 — Explain the two-doors analogy to a skeptical colleague. A colleague tells you: "if the if: condition is written correctly, it works just as well as two separate files — it's just a style preference." Answer them in two or three sentences.
See solution
A complete answer sounds, roughly, like this: "It's true that, if the condition never fails, the result is the same — but 'never fails' is exactly the guarantee you can't give for a condition inside a file that an unreviewed Pull Request could, in theory, try to modify. With two files separated by triggering event, there's no condition to review: apply.yml simply doesn't exist as a possibility until the event is push to main, something that only happens after the merge. It's not a style preference, it's eliminating an entire category of possible error."
Exercise 3 — Decide which workflow each action belongs to. For each of these actions, decide whether it belongs to ci.yml (this module) or to apply.yml (Module 5): (a) terraform fmt -check; (b) terraform apply on the saved plan; (c) terraform plan; (d) publishing the plan in the job's summary for someone to review.
See solution
(a) ci.yml — fmt applies nothing, it's a style check, it runs on every PR. (b) apply.yml — only this file has the conceptual permission to apply, and it only runs after merging to main. (c) ci.yml — the plan is exactly the review artifact this module builds. (d) ci.yml — publishing the plan for review is this workflow's whole reason for existing; without this step, nobody would have anything to read before approving the merge.
Summary and next step
In this lesson you confirmed, against the official source, the pattern organizing this module: HashiCorp documents two workflows separated by triggering event —one that calculates and shows (pull_request), another that applies (push to main)— with the plan published as review evidence before the merge. This guide adapts that pattern with the terraform/tflocal binary running directly against LocalStack, instead of HCP Terraform, keeping the same security principle: structural separation by file and by event, not a condition inside a shared file.
Before moving on you should be able to: explain from memory why ci.yml and apply.yml are distinct files, not one file with a condition; name the concrete vulnerability ("pwn request") the structural separation avoids; and distinguish which part of HashiCorp's tutorial this guide follows to the letter (the workflow separation, the plan as evidence) from which part it adapts (the local binary instead of HCP Terraform).
Lesson 3 continues with the how: installing Terraform inside the ephemeral runner that's going to execute this guide's first real fmt/validate/plan.
Resources
- HashiCorp Developer — Automate Terraform with GitHub Actions — this lesson's official source for the pattern, verified today.
- GitHub Docs — Security hardening for GitHub Actions — official documentation on Pull Request risks and permission separation, the basis for this lesson's security argument.
- actions/github-script — the Action HashiCorp's tutorial uses to comment the plan on the PR, gone into depth in this module's lesson 7.