Terraform · lesson 14 of 17
Beginner
7- 1 Terraform Fundamentals (IaC)
- 2 Terraform Workflow (init → validate → plan → apply)
- 3 Terraform State (tfstate, remote state, locking)
- 4 Terraform Variables and Outputs
- 5 Providers and Resources (the building blocks of Terraform)
- 6 Terraform Locals: Computed Values Without Repetition
- 7 Terraform Modules (reuse, composition, and clean structure)
Intermediate
7- 8 Terraform count and for_each (repeat resources safely)
- 9 The lifecycle Block: prevent_destroy, create_before_destroy, ignore_changes
- 10 Data Sources and Dependencies (read existing infrastructure)
- 11 sensitive = true: What It Hides, and What It Doesn't
- 12 Remote State Safety (locking, drift, and recovery)
- 13 Workspaces and Environments (dev/stage/prod patterns)
- 14 Terraform CI/CD with GitHub Actions (plan/apply)
Advanced
3Also part of Terraform
4Also part of: DevOps GitHub Actions
Terraform CI/CD with GitHub Actions (plan/apply)
A GitHub Actions workflow for Terraform, with the real output of each step it runs: fmt -check, init, validate, plan -detailed-exitcode and a saved plan.
By SkillByExample · Updated
A Terraform pipeline repeats the same commands you run by hand, with no prompts, and fails
on a non-zero exit. This lesson shows the workflow file first, which is configuration. Then
come the real outputs of each command it runs, captured locally with Terraform 1.16.5 and
hashicorp/random 3.9.1 so they need no cloud account.
The workflow (configuration)
.github/workflows/terraform.yml:
name: terraform
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
plan:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.16.5
- run: terraform fmt -check -recursive
- run: terraform init -input=false
- run: terraform validate
- run: terraform plan -input=false -no-color -out=tfplan
- run: terraform show -no-color tfplan > plan.txt
- uses: actions/upload-artifact@v4
with:
name: plan
path: plan.txt
apply:
if: github.event_name == 'push'
runs-on: ubuntu-latest
environment: production # requires an approval configured in repo settings
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.16.5
- run: terraform init -input=false
- run: terraform plan -input=false -no-color -out=tfplan
- run: terraform apply -input=false -no-color tfplan
The pull request job publishes a readable plan for reviewers. The apply job plans again from
the merged code and applies that exact saved plan, after someone approves the production
environment. A real setup also needs credentials, usually OIDC through
aws-actions/configure-aws-credentials or its Azure or GCP equivalent, and a remote backend.
Neither appears here because neither can be run without an account. Everything below is what
each run: step prints.
Step 1: fmt -check fails the build
The configuration being checked, a root module and one local module:
# main.tf
terraform {
required_version = "~> 1.16"
required_providers {
random = {
source = "hashicorp/random"
version = "~> 3.9"
}
}
}
module "naming" {
source = "./modules/naming"
env = "ci"
}
resource "random_pet" "release" {
prefix = module.naming.prefix
}
output "release" {
value = random_pet.release.id
}
# modules/naming/main.tf
variable "env" {
type = string
}
output "prefix" {
value="shop-${var.env}"
}
$ terraform fmt -check -recursive; echo "exit=$?"
modules/naming/main.tf
exit=3
$ terraform fmt -check -recursive -diff -no-color
modules/naming/main.tf
--- old/modules/naming/main.tf
+++ new/modules/naming/main.tf
@@ -3,5 +3,5 @@
}
output "prefix" {
- value="shop-${var.env}"
+ value = "shop-${var.env}"
}
$ terraform fmt -recursive
modules/naming/main.tf
-check only reports, and exit status 3 fails the step. -recursive matters because without
it fmt only looks at the current directory and would never see the module. Add -diff to
the CI step so the log shows the fix. The author runs terraform fmt -recursive locally and
pushes again.
Steps 2 and 3: init and validate
$ terraform init -input=false -no-color | grep -E "^- |successfully"
- naming in modules/naming
- Finding hashicorp/random versions matching "~> 3.9"...
- Installing hashicorp/random v3.9.1...
- Installed hashicorp/random v3.9.1 (signed by HashiCorp)
Terraform has been successfully initialized!
$ terraform validate -no-color
Success! The configuration is valid.
-input=false matters in CI. Without it, a missing variable or backend setting makes
Terraform wait for keyboard input that will never come, until the job times out. With it,
Terraform fails immediately with an error that names the missing value.
Step 4: a saved plan, and what the exit code says
$ terraform plan -input=false -out=tfplan -detailed-exitcode -no-color; echo "exit=$?"
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# random_pet.release will be created
+ resource "random_pet" "release" {
+ id = (known after apply)
+ length = 2
+ prefix = "shop-ci"
+ separator = "-"
}
Plan: 1 to add, 0 to change, 0 to destroy.
Changes to Outputs:
+ release = (known after apply)
─────────────────────────────────────────────────────────────────────────────
Saved the plan to: tfplan
To perform exactly these actions, run the following command to apply:
terraform apply "tfplan"
exit=2
$ terraform show -no-color tfplan > plan.txt && wc -l plan.txt
19 plan.txt
Exit code 2 means “the plan has changes”, and that’s success. GitHub Actions treats any
non-zero exit as a failed step, which is why the workflow above leaves out
-detailed-exitcode. Add it only to a step that captures the code, for example to skip the
apply when it’s 0. plan.txt holds just the plan, without the “Saved the plan” footer, which
is the part worth posting on the pull request.
Step 5: apply the saved plan
$ terraform apply -input=false -no-color tfplan
random_pet.release: Creating...
random_pet.release: Creation complete after 0s [id=shop-ci-destined-bobcat]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
Outputs:
release = "shop-ci-destined-bobcat"
Applying a plan file doesn’t ask for confirmation, so the approval has to happen before this
step. In the workflow, the environment: production gate is that approval.
Practice
1. After the apply, run the plan again with -detailed-exitcode. What does a scheduled
“is production still as declared?” job see?
Solution
$ terraform plan -input=false -detailed-exitcode -no-color > /dev/null; echo "exit=$?"
exit=0
0 means no changes and no drift. A nightly workflow running this command can open an issue whenever it gets 2.
2. CI installs Terraform 1.6.0 because someone copied an old workflow. What happens to this configuration?
Solution
$ terraform version | head -2
Terraform v1.6.0
on linux_amd64
$ terraform init -input=false -no-color
Initializing the backend...
Initializing modules...
Error: Unsupported Terraform Core version
on main.tf line 2, in terraform:
2: required_version = "~> 1.16"
This configuration does not support Terraform version 1.6.0. To proceed,
either choose another supported Terraform version or update this version
constraint. Version constraints are normally set for good reason, so updating
the constraint may lead to other errors or unexpected behavior.
required_version stops the old binary before it touches state. Without that constraint, an
old CLI might fail later with a confusing error, or misread state written by a newer version.
Keep terraform_version in the workflow and required_version in the code in step.
3. Break the formatting on purpose in main.tf (e.g. env="ci"), and fix it with one
command before pushing.
Solution
$ terraform fmt -check -recursive; echo "exit=$?"
main.tf
exit=3
$ terraform fmt -recursive
main.tf
$ terraform fmt -check -recursive; echo "exit=$?"
exit=0
fmt lists only the files it rewrote, and the follow-up -check that exits 0 is exactly what
CI will see. Many teams run terraform fmt -recursive in a pre-commit hook so this check never
fails on formatting.
Next: provisioners, and why HashiCorp calls them a last resort.