Module 5: Apply On Merge The Cd Half
1. Introduction: from review to apply
Description
The four previous modules built everything needed before touching real infrastructure: you know why a manual apply is a problem (Module 1), you know how to read and write any GitHub Actions workflow with act (Module 2), you have a ci.yml that runs fmt/validate/plan on every Pull Request and publishes that plan as review evidence (Module 3), and you know how to pass credentials to a pipeline without compromising them, including the full honesty about OIDC and environments with approval (Module 4). What's missing is the half that gives meaning to everything before it: the real apply, triggered automatically, only when the change has already merged into main, applying exactly the plan a person reviewed — not a new one, recalculated blindly.
This module builds that half. By the end of it you're going to have apply.yml —the workflow that runs terraform apply without anyone typing it—, drift.yml —which detects changes made outside Terraform, periodically—, and you're going to understand exactly what risk a concurrency: block prevents, one you haven't needed until now.
Connection to the module
This Module 5 is the direct counterpart to Module 3. Where ci.yml answered "what would change, and who reviews it?", apply.yml answers "who applies it, and when?" — with a very concrete answer: nobody types it, and only when the code is already on main. Lessons 2 and 3 give the why and the how of the trigger and the chaining between ci.yml and apply.yml; lesson 4 —hands-on— builds apply.yml complete and runs it for real; lesson 5 closes a risk that didn't exist until there was a real apply: two simultaneous runs writing over the same state. Lessons 6 and 7 add drift.yml, the piece that watches infrastructure between deployments. Lesson 8 —this module's project— runs the three workflows in sequence, on the same repository you've been building since Module 1.
What's missing from the pipeline to do what you did by hand
Review, precisely, what the terraform-and-iac-guide guide did when apply was still manual: you ran terraform plan, read the output in your terminal, and if it convinced you, ran terraform apply — the same person, in the same terminal session, deciding and executing. This guide's Module 3 already automated the first half: ci.yml runs that same plan on every Pull Request, without anyone typing it. But through the end of Module 4, if someone wanted to apply that change, they'd still have to do it by hand — no workflow in this guide, until now, has run terraform apply even once.
WHAT ALREADY EXISTS (end of Module 4) WHAT'S MISSING (this module)
Pull Request git push to main (merge)
│ │
▼ ▼
ci.yml (pull_request) apply.yml (push to main)
├─ fmt ├─ downloads the SAME plan
├─ validate │ ci.yml calculated
├─ plan ──────► plan-output.txt ├─ terraform apply
└─ publishes the plan └─ real infrastructure
(STEP_SUMMARY) created/updated
│
▼
a person reviews the plan
and approves the merge drift.yml (schedule)
└─ watches for changes outside
Terraform, every day
Notice the dotted arrow between "publishes the plan" and "a person reviews": that's exactly where Module 4 stopped — with the plan visible, but no mechanism to apply it automatically after approval. This module draws the rest of the diagram.
This module's map: the 8 lessons
| # | Lesson | What you practice |
|---|---|---|
| 1 | Introduction (this one) | The complete map; what's missing for the pipeline to do what you did by hand |
| 2 | The merge trigger: push to main | on: push: branches: [main]; why apply.yml must never run on a feature branch |
| 3 | Chaining jobs with needs and passing the exact plan | needs: within the same workflow; actions/upload-artifact/download-artifact so apply uses the plan that was already reviewed |
| 4 | Hands-on: building apply.yml | Executed: the complete workflow, run with act push, with the real apply against representative LocalStack |
| 5 | Concurrency control: avoiding the double apply | concurrency: { group, cancel-in-progress }; the same lock risk you already saw with Terraform |
| 6 | Scheduled drift detection | on: schedule: cron:; the complete syntax, the real timer as representative |
| 7 | Hands-on: running the drift job manually | Executed: drift.yml run with act workflow_dispatch |
| 8 | Project: the complete plan-to-apply pipeline | Executed: ci.yml + apply.yml + drift.yml in sequence, on a real change |
What this module does NOT build
Two boundaries, declared now so you keep them in mind through every lesson:
- Rollback and safety nets —reverting an
applythat went wrong, branch protection, a guardrail that blocks destroying theShipmentstable— is all of Module 6. This module applies changes; the next one teaches what to do when an applied change turns out to be a mistake. - End-to-end federated OIDC against a real AWS account was already named and shown in YAML in Module 4 (
04-what-is-oidc-federation.md,05-the-oidc-workflow-pattern-named.md); this module keeps using exactly the same.secretsmechanism with dummy credentials Module 4 established for LocalStack —apply.ymldoesn't introduce any new identity mechanism—. The complete build-out of OIDC against a real account lives incloud-security-and-guardrails-guide.
This specific module's execution honesty
The same pattern from the four previous guides, with a new nuance worth naming now: the apply.yml job itself runs for real with act push —it's not representative—, but the terraform apply that job tries to run against LocalStack does depend on a valid LOCALSTACK_AUTH_TOKEN, exactly like every awslocal in the previous guides. The difference from Module 3 is that there, terraform plan managed to complete without needing that connection (thanks to skip_requesting_account_id, that lesson's finding); a real terraform apply, on the other hand, does need to create real resources against a reachable endpoint — there's no way around it, because applying means, literally, making write calls to the API. You're going to see, in lesson 4, the real attempt, with its real error, at exactly the same level of detail you already saw in Module 2 (lesson 8) and Module 3 (lesson 5).
drift.yml, in lessons 6 and 7, follows the same pattern as ci.yml: the read-only terraform plan that runs inside that job does manage to complete without LocalStack running (the same mechanism from Module 3), so the job itself runs end-to-end with literal output — what stays representative is the part that requires a valid token: modifying something with awslocal directly to simulate a change made outside Terraform.
Common mistakes
Assuming apply.yml recalculates the plan (conceptual, the mistake this module exists to prevent). What happens: someone builds apply.yml with a terraform plan step followed by a terraform apply step, thinking it's the same thing ci.yml does. Why it happens: it seems reasonable that "applying" would include "calculating what to apply first." How to spot it: if your apply.yml has the word plan in any run:. How to fix it: as you already saw in Module 3 (lesson 2), the HashiCorp/GitHub pattern exists exactly to prevent this — between the moment someone approves a plan and the moment it merges, the real state could have changed; recalculating would apply something different from what was approved. This module's lesson 3 builds the correct mechanism: downloading the same plan file ci.yml already calculated.
Believing this module replaces Module 3 (scope-based). What happens: someone thinks that, once apply.yml exists, ci.yml stops being necessary. How to fix it: they're complementary pieces, not sequential ones — ci.yml keeps running on every new Pull Request, forever; apply.yml only comes into play after a PR merges. Both files coexist in .github/workflows/ permanently.
Exercises
Exercise 1 — Translate this module's goal without using the word "apply." In two sentences, describe what's missing from the pipeline built in Modules 1 through 4 to complete the full GitOps pattern.
See solution
A complete answer sounds, roughly, like this: "So far, the pipeline knows how to calculate what would change and show it to someone for review, but no workflow writes real infrastructure yet — every change, once approved, would still require a person to apply it by hand. This module closes that last manual step: when the change reaches the main branch, the pipeline itself materializes it, without anyone typing the command."
Exercise 2 — Locate the boundary with Module 6. Without looking at this guide's DESIGN, what do you think would happen if this module's apply.yml, by mistake, applied a change that destroys the Shipments table? Does this module stop it?
See solution
No — this module builds the application mechanism, with no content guardrail yet. A guardrail that reviews the JSON plan and fails the job if it tries to destroy Shipments is, specifically, Module 6's job (lesson 7). This module trusts that the human review of the plan (Module 3) already caught any dangerous change before the merge — Module 6 adds a second, automated safety net for when that human review fails.
Exercise 3 — Predict what runs with act push in this module. Before reading lesson 4, what do you expect to happen if you run act push on a freshly built apply.yml, with no LOCALSTACK_AUTH_TOKEN exported and no LocalStack running?
See solution
You'd expect to see act's job run for real —download the plan artifact, install Terraform, run terraform init— up to the terraform apply step, which would fail with a real connection error (similar to the Could not connect to the endpoint URL you already saw in Module 2 and Module 3), after several seconds of retrying. The job would end red, but for the right reason: LocalStack isn't running, not because the YAML is written wrong.
Summary and next step
In this lesson you saw Module 5's complete map: exactly what's missing for the pipeline to apply real infrastructure without human intervention, how it connects to the four previous modules, and this module's specific execution honesty —the apply.yml job runs for real with act push, the apply against LocalStack is representative without a token, and drift.yml runs end-to-end because its read-only plan doesn't need that connection—.
Before moving on you should be able to: explain in one sentence what's missing from the Modules 1-4 pipeline to be complete; name this module's two new pieces (apply.yml, drift.yml); and anticipate, without surprises, what you'll be able to run for real and what will stay representative.
Lesson 2 starts at the beginning: the exact trigger that turns "a change merged" into "the pipeline must apply it now."
Resources
- GitHub Docs — GitHub Actions — official documentation, the basis for this entire module.
- HashiCorp Developer — Automate Terraform with GitHub Actions — the complete pattern, already cited since Module 3, that this module finishes implementing.
- This guide's Module 3 (
the-iac-pipeline-fmt-validate-plan) — theci.ymlthis module extends, not replaces. - This guide's Module 4 (
secrets-environments-and-identity) — the credential handlingapply.ymlreuses unchanged.