Module 3: The Iac Pipeline Fmt Validate Plan
3. Installing Terraform inside an ephemeral runner
Description
In terraform-and-iac-guide you installed Terraform once, on your laptop, and it stayed there — every terraform plan you ran afterward, in any lesson of that guide, found the binary already installed, exactly where you left it. This lesson shows you why that does not work the same way inside a GitHub Actions job (real or simulated with act): every run starts from a clean container, with no Terraform, with nothing you installed in a previous run. You're going to confirm this with your own eyes —a command that looks for terraform before installing it, and only finds it afterward— and you're going to meet the piece that solves this on every run: hashicorp/setup-terraform@v3, HashiCorp's official Action, pinned to the exact version this module uses from here on.
Connection to the module
Lesson 2 gave you the complete pattern (ci.yml calculates, apply.yml applies). This lesson solves a technical prerequisite for both: neither one can run a single Terraform command if the runner doesn't have the binary installed — and, unlike your laptop, a runner doesn't remember it from one run to the next. Lessons 4, 5, and 6 —where fmt, validate, and plan really run inside act— depend directly on what you install here.
Analogy: a workshop that gets torn down after every job, not a fixed toolbox
Your laptop, in terraform-and-iac-guide, is a permanent workshop: you installed the workbench, hung the tools on the wall, and the next day everything's still exactly where you left it. A GitHub Actions runner —real or simulated with act— is the opposite: an hourly-rented workshop that gets completely torn down at the end of every job and rebuilt from scratch for the next one. If you need a hammer, you can't assume "it's already there because I used it yesterday" — you have to bring it yourself, every time, as part of the job. hashicorp/setup-terraform@v3 is, literally, the step where you bring that tool into the freshly built workshop, at the start of every run — never assumed, always explicit.
Confirming it with your own eyes: the runner remembers nothing
Before building ci.yml, it's worth seeing this fact directly, on a minimal, disposable workflow, outside andes-cargo-infra/:
name: tf-version-demo
on: workflow_dispatch
jobs:
show-terraform-version:
runs-on: ubuntu-latest
steps:
- name: Check for terraform before setup
run: |
which terraform || echo "terraform not found in this fresh container"
- name: Set up Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.15.8"
- name: Show terraform version
run: terraform version
act workflow_dispatch -j show-terraform-version
What to expect (literal output, executed to write this lesson):
[tf-version-demo/show-terraform-version] ⭐ Run Main Check for terraform before setup
[tf-version-demo/show-terraform-version] 🐳 docker exec cmd=[bash -e /var/run/act/workflow/0] user= workdir=
[tf-version-demo/show-terraform-version] | terraform not found in this fresh container
[tf-version-demo/show-terraform-version] ✅ Success - Main Check for terraform before setup [48.184584ms]
[tf-version-demo/show-terraform-version] ⭐ Run Main Set up Terraform
[tf-version-demo/show-terraform-version] 🐳 docker cp src=/Users/.../hashicorp-setup-terraform@v3/ dst=/var/run/act/actions/hashicorp-setup-terraform@v3/
[tf-version-demo/show-terraform-version] 🐳 docker exec cmd=[/opt/acttoolcache/node/24.19.0/arm64/bin/node /var/run/act/actions/hashicorp-setup-terraform@v3/dist/index.js] user= workdir=
[tf-version-demo/show-terraform-version] | [command]/usr/bin/unzip -o -q /tmp/af7217a0-6559-4fde-bd65-19b381b0d85c
[tf-version-demo/show-terraform-version] ✅ Success - Main Set up Terraform [2.287155375s]
[tf-version-demo/show-terraform-version] ⭐ Run Main Show terraform version
[tf-version-demo/show-terraform-version] 🐳 docker exec cmd=[bash -e /var/run/act/workflow/2] user= workdir=
[tf-version-demo/show-terraform-version] | Terraform v1.15.8
[tf-version-demo/show-terraform-version] | on linux_arm64
[tf-version-demo/show-terraform-version] ✅ Success - Main Show terraform version [475.684125ms]
[tf-version-demo/show-terraform-version] 🏁 Job succeeded
Read the three key lines in order: terraform not found in this fresh container (before setup-terraform runs, the binary simply doesn't exist in this image — the catthehacker/ubuntu:act-latest image you chose in Module 1 doesn't ship it preinstalled, on purpose, per its own documentation's warning) → [command]/usr/bin/unzip -o -q ... (setup-terraform downloads and unpacks the exact binary for the version you asked for) → Terraform v1.15.8 on linux_arm64 (only now does the terraform command exist and respond). If you ran this same workflow a second time, in a second run, you'd see exactly the same sequence — terraform not found again at the start — because every run is a new container, with no memory of the previous run.
hashicorp/setup-terraform@v3, the piece that solves this
- name: Set up Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.15.8"
You already know uses/with from Module 2 (lesson 5), where you saw actions/checkout@v4 and named hashicorp/setup-terraform@v3 as the next Action you were going to use. This lesson puts it to use: hashicorp/setup-terraform is the official Action, maintained by HashiCorp, that downloads the terraform binary for the version you ask for and adds it to the runner's PATH —the exact mechanism you saw in the output above: TERRAFORM_CLI_PATH and an add-path at the end of the step. From that point on, any following step in the same job can invoke terraform as if it were preinstalled.
Why terraform_version: "1.15.8", exact, not a range
terraform-and-iac-guide pinned required_version = ">= 1.15.0" in Andes Cargo's versions.tf —a minimum, not an exact version, to give room for future patches without breaking. Here, on the other hand, terraform_version in setup-terraform is set to "1.15.8", exact: the same version you installed on your laptop in that guide, and the one that unambiguously satisfies the project's required_version. The difference in purpose matters: required_version in the HCL is a constraint Terraform itself validates ("don't run with a version older than this"); terraform_version in setup-terraform is an installation instruction ("download exactly this one"). Pinning an exact number on the runner —instead of letting setup-terraform install "the latest" (its behavior if you omit this parameter)— is the same reproducibility discipline you already saw with .actrc pinning the runner image in Module 1: every run of ci.yml, today or six months from now, installs the same exact version, no surprises.
Going deeper: what DOES persist between runs, and what doesn't
It's worth being precise about exactly what disappears between one run and the next, because it's not "absolutely everything":
- Doesn't persist: the job container's complete file system —any binary you installed with a
run:(liketerraform,awslocal,tflocalin upcoming lessons), any file you created by hand inside the job that isn't part of your repository or wasn't explicitly uploaded as an artifact. - Does persist (because it doesn't live in the job's container): your repository's own code —
actions/checkoutbrings it in on every run, from Git, not from the previous container— Terraform's state, if you used a remote backend (not this guide's case, which uses a local backend inside the checkout itself —terraform-and-iac-guide's Module 4 covers that distinction in depth); and any artifact a job explicitly uploads withactions/upload-artifactfor another job to download —the exact mechanism this guide's Module 5 uses to pass theplanfromci.ymltoapply.yml.
The simple rule: if you didn't bring it in with checkout, didn't download it with an artifact Action, and didn't install it in this very run, it doesn't exist. setup-terraform exists precisely because the Terraform binary falls into the "has to be installed every time" category.
Common mistakes
Omitting terraform_version and assuming it installs "whatever you use" (configuration-based). What happens: someone writes uses: hashicorp/setup-terraform@v3 with no with: at all, assuming the Action detects and respects the project's versions.tf's required_version. Why it happens: it seems reasonable that the Action would "read" the project's configuration before installing anything. How to spot it: if your ci.yml has no explicit terraform_version and you're surprised by which version ended up installed. How to fix it: setup-terraform doesn't read your HCL — without an explicit terraform_version, it installs the most recent version available at run time, which might not match what you use on your laptop. Always pin terraform_version explicitly, the same way you pinned the runner image in .actrc.
Confusing required_version (in the HCL) with terraform_version (in the YAML) as if they were the same thing (conceptual). What happens: someone thinks changing one automatically updates the other, or that only one of the two needs to be declared. How to spot it: if you edit versions.tf expecting ci.yml to install a different version without touching the YAML. How to fix it: they're two independent mechanisms with different purposes — required_version is a minimum constraint Terraform validates at run time; terraform_version is an installation instruction setup-terraform executes before Terraform even runs. Keeping them consistent (the installed version satisfies the minimum required) is the responsibility of whoever writes the YAML, not something automatic.
Expecting a run: terraform ... to work in a step before Set up Terraform (order-based). What happens: someone reorders ci.yml's steps and puts a Terraform command before the uses: hashicorp/setup-terraform@v3 step. How to spot it: a terraform: command not found-type error in a step that should work. How to fix it: remember steps is an ordered list (Module 2, lesson 2) — setup-terraform has to appear, in the file, before any step invoking terraform, terraform fmt, terraform validate, or tflocal.
Exercises
Exercise 1 — Predict a second run's result. If you ran this lesson's tf-version-demo.yml workflow a second time, immediately after the first, without changing anything, would you expect the first step (Check for terraform before setup) to print terraform not found again? Justify it.
See solution
Yes, exactly the same result. Every act run (and every real GitHub Actions job run) starts a new container, based on the unmodified catthehacker/ubuntu:act-latest image — nothing you installed in the previous run survives. The second run doesn't "remember" that you already installed Terraform the first time; it starts over from scratch, exactly like the first.
Exercise 2 — Explain the difference between required_version and terraform_version to a colleague. Without repeating the lesson's exact definitions, explain in two sentences why a project needs both, and what would happen if it only had one.
See solution
A complete answer sounds, roughly, like this: "required_version, in the HCL, is the project saying 'don't run me with a Terraform version older than this' — it protects against using an incompatible version. terraform_version, in the workflow's YAML, is the step that installs Terraform on the runner before anything else can run — without that, there's no Terraform installed that could even check required_version. If you only had required_version with no explicit terraform_version in the workflow, setup-terraform would install 'the latest' by default, which today probably satisfies the minimum, but isn't a long-term guarantee."
Exercise 3 — Diagnose a terraform: command not found. A colleague shows you a ci.yml job that fails with terraform: command not found in a step called Terraform format check. Without seeing the rest of the file, what's the first thing you'd check, based on what you learned in "Common mistakes"?
See solution
You'd check whether the uses: hashicorp/setup-terraform@v3 step appears before, in the file's order, the Terraform format check step. Since steps is an ordered list and every job starts with no Terraform installed, any step invoking terraform has to come, literally, after the step that installs it — if the order is reversed, or if the installation step is missing entirely, the command not found error is exactly what you'd expect to see.
Summary and next step
In this lesson you confirmed, with a command that looks for terraform before and after installing it, that a runner —real or simulated with act— remembers nothing between runs: every job starts from a clean container, with no tool you installed in a previous run. hashicorp/setup-terraform@v3, pinned to terraform_version: "1.15.8" —the same exact version you installed in terraform-and-iac-guide— is the piece that brings Terraform into that clean runner, on every run, no exceptions.
Before moving on you should be able to: explain why a runner doesn't behave like your laptop when it comes to installed tools; distinguish required_version (HCL) from terraform_version (YAML) without confusing them; and correctly place setup-terraform in a job's steps order.
With Terraform installable on every run, lesson 4 uses exactly this piece to build ci.yml's first two real steps: terraform fmt -check and terraform validate, run inside act, with an error introduced on purpose to watch the job fail red.
Resources
- GitHub — hashicorp/setup-terraform — the official repository for the Action used in this lesson, with the complete reference for its parameters.
- nektosact.com — Runners — official documentation explaining why
act's images don't ship every tool a real runner has preinstalled. terraform-and-iac-guide, Module 1, lesson 5 (NIEVA) — installing Terraform 1.15.8 on your laptop, this lesson's direct contrast.