Terraform in CI/CD & Testing
Why this matters
Running terraform apply from your laptop is fine for learning, but it's
exactly the un-auditable, "works on my machine" pattern IaC was supposed to
kill. Real teams run Terraform from a pipeline: a push opens a PR, the
pipeline runs fmt/validate/plan and posts the plan for review, and a
merge runs apply with credentials no human holds. This module bridges
toward track 10 (CI/CD and GitOps) by showing the pipeline shape and the
quality gates — formatting, validation, linting, and policy checks — that
belong in front of every apply.
Concepts
Why Terraform belongs in a pipeline
The same reasons declarative IaC beat imperative az (module 00) compound
when you automate them:
- The reviewed plan is the applied plan. With
plan -out=tfplan→apply tfplan(module 01), the pipeline applies exactly what a reviewer saw in the PR — no drift between review and execution. - No human holds prod credentials. The pipeline authenticates as a service principal (or, better, via OIDC), so applying to production doesn't require anyone to have standing admin rights on their laptop.
- Every change is gated and logged.
fmt/validate/lint/policychecks run automatically; the run log is the audit trail.
The canonical pipeline stages
A Terraform pipeline is almost always these stages, in order:
terraform fmt -check— fails if any file isn't canonically formatted. Cheap, catches style drift.terraform init— with the remote backend (module 05) and the committed lock file for reproducible provider versions.terraform validate— syntax and internal-reference check (module 00).- Lint / policy —
tflintfor provider-specific best practices,checkov(ortfsec) for security/policy scanning of the plan or config. terraform plan -out=tfplan— on a PR, this is posted for human review; nothing is applied.terraform apply tfplan— only on merge to the main branch, applying the exact reviewed plan.
The split matters: plan runs on the PR, apply runs on merge. A reviewer approves the plan; the merge triggers the apply of that same plan.
Authenticating a pipeline to Azure
A pipeline has no az login session, so it uses non-interactive auth via
the ARM_* environment variables the azurerm provider reads:
- Service principal with a secret:
ARM_CLIENT_ID,ARM_CLIENT_SECRET,ARM_TENANT_ID,ARM_SUBSCRIPTION_ID. Simple, but the secret must be stored in the CI system and rotated. - OIDC / workload identity federation (preferred): the pipeline gets a
short-lived token from its CI provider, exchanged for an Azure token —
no stored secret at all. Set
ARM_USE_OIDC=truealongsideARM_CLIENT_ID/ARM_TENANT_ID/ARM_SUBSCRIPTION_ID. This is the same OIDC-federation idea track 7's CI module used for pushing to ACR, applied to Terraform. You'll master the mechanics in track 10; here, recognize that "no long-lived secret in CI" is the goal.
A GitHub Actions skeleton
Conceptually (you'll build the real thing in track 10), a plan-on-PR job
looks like this — note it uses HashiCorp's setup-terraform action and
authenticates via OIDC:
name: terraform-plan
on: pull_request
permissions:
id-token: write # required for OIDC
contents: read
jobs:
plan:
runs-on: ubuntu-latest
env:
ARM_USE_OIDC: "true"
ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform fmt -check
- run: terraform init
- run: terraform validate
- run: terraform plan -out=tfplan
The apply job is nearly identical but triggers on: push to main and
runs terraform apply -auto-approve tfplan (applying the saved plan;
-auto-approve is acceptable here precisely because a human already
reviewed the plan on the PR).
terraform validate vs fmt vs lint vs policy
Four checks that people conflate but that catch different things:
terraform fmt— formatting only (indentation, alignment). Says nothing about correctness.terraform validate— internal consistency: syntax, argument names, references resolve. Doesn't contact Azure, doesn't judge quality.tflint— best-practice/lint: deprecated syntax, invalid instance/VM types, provider-specific pitfallsvalidatewon't catch.checkov/tfsec— security & policy: "this storage account allows public blob access," "this NSG rule opens 22 to the world." These scan for misconfigurations, not syntax.
You want all four, and they run cheaply before any apply.
A taste of terraform test
Terraform 1.6+ ships a native testing framework: .tftest.hcl files with
run blocks that execute plan (or apply) against your config and assert
on the results:
# tests/network.tftest.hcl
run "vnet_has_expected_cidr" {
command = plan
assert {
condition = azurerm_virtual_network.hub.address_space[0] == "10.0.0.0/16"
error_message = "Hub VNet CIDR must be 10.0.0.0/16"
}
}
terraform test runs these. A plan-based test is fast and free (no
resources created); an apply-based test creates real resources, asserts,
then destroys them — powerful but billable, so use plan tests for most
logic checks. This is the direction infrastructure testing is heading;
you're seeing the shape, not mastering it, here.
Command reference
| Command | What it does | Example |
|---|---|---|
terraform fmt -check |
Fails (non-zero exit) if any file isn't formatted; changes nothing | terraform fmt -check -recursive |
terraform validate |
Checks config syntax and references | terraform validate |
terraform plan -out=tfplan |
Saves the plan for a later exact apply | terraform plan -out=tfplan |
terraform apply tfplan |
Applies the saved plan with no prompt | terraform apply tfplan |
terraform show -json tfplan |
Emits the saved plan as JSON (for policy tools to scan) | terraform show -json tfplan > plan.json |
terraform test |
Runs .tftest.hcl test files |
terraform test |
tflint |
Lints for provider best-practices | tflint --init && tflint |
checkov -d . |
Scans config/plan for security/policy issues | checkov -d . |
Flag breakdown — terraform fmt -check -recursive:
-check— exit non-zero if files would be reformatted, but don't rewrite them. This is the CI-appropriate form (fail the build; don't silently mutate).-recursive— also check subdirectories (so child modules are covered).
Flag breakdown — terraform apply tfplan (vs interactive apply):
- Passing a saved plan file makes apply skip re-computation and the
confirmation prompt — it applies precisely that plan. Combined with
plan -outon the PR, it guarantees the applied change equals the reviewed change.
Hands-on exercises
Mostly free — you'll run the local checks that a pipeline runs. A couple of optional steps install external tools; the plan/validate/fmt steps need only Terraform.
Set up a small config to gate. New directory with a resource group + VNet (reuse module 02's shape):
mkdir -p ~/tf-labs/07-cicd && cd ~/tf-labs/07-cicd export ARM_SUBSCRIPTION_ID="$(az account show --query id -o tsv)"Create
main.tf(provider skeleton + RG + a VNet), thenterraform init.Run the pipeline's format gate. Deliberately misformat
main.tf(mangle indentation), then run the check form:terraform fmt -check -recursiveVerify: it exits non-zero and names the unformatted file — this is what fails a CI build. Fix it with
terraform fmt, then rerunterraform fmt -checkand confirm it now exits 0 (clean).Run the validate gate. Introduce a typo'd argument (e.g.
adress_space), run:terraform validateVerify: it fails with the unexpected-argument error. Fix the typo and confirm
validatereports success. This is CI stage 3.Produce and inspect a saved plan. Generate the artifact a reviewer would approve:
terraform plan -out=tfplan terraform show tfplan | head -40 terraform show -json tfplan > plan.jsonVerify:
tfplan(binary) andplan.jsonexist. The JSON is what policy tools likecheckovscan. Note: you'd never committfplan— add it to.gitignore(it may contain sensitive values).Apply the exact saved plan. Prove plan→apply fidelity:
terraform apply tfplanVerify: it applies with no confirmation prompt and no re-computation — exactly the reviewed plan. This is why CI uses
-outthenapply <file>.Write and run a
terraform test. Createtests/vnet.tftest.hcl:run "vnet_cidr_is_correct" { command = plan assert { condition = azurerm_virtual_network.hub.address_space[0] == "10.0.0.0/16" error_message = "Hub VNet CIDR must be 10.0.0.0/16" } }(Adjust the resource local name to match yours.) Run:
terraform testVerify: it reports the test passed. Now break it — change the assert's expected CIDR to
10.99.0.0/16and rerun; confirm the test fails with your error message. Thisplan-based test created nothing in Azure. Revert the assertion.Diagnose and fix: a policy check catches a real risk. Add an NSG rule that opens SSH to the whole internet (a genuine misconfiguration):
security_rule { name = "allow-ssh-anywhere" priority = 100 direction = "Inbound" access = "Allow" protocol = "Tcp" source_port_range = "*" destination_port_range = "22" source_address_prefix = "*" # <-- the problem: open to the world destination_address_prefix = "*" }(Attach it to an NSG resource.) If you have
checkovinstalled (pip install checkov), runcheckov -d .; otherwise reason it through.Verify / reason:
checkovflags a check like "Ensure NSG does not allow SSH (22) from the internet." Fix by scopingsource_address_prefixto a specific admin CIDR (or a Bastion subnet) instead of*— the same track 5 NSG discipline, now enforced automatically by policy scanning rather than a reviewer's memory. This is the class of bug CI policy checks exist to stop before apply.Optional: run
tflint. If you installtflint(brew/download), runtflint --init && tflintin the directory.Verify / reason: it surfaces provider best-practice issues (e.g. an invalid VM size, deprecated arguments) that
validatepasses over. Note where each of your four gates would catch a different bug.Sketch the pipeline (no apply). In a
READMEor comment in your repo, write the six pipeline stages in order and mark which run on PR (plan) versus merge (apply), and note that the pipeline would authenticate viaARM_USE_OIDC=truewith no stored secret. You'll build this for real in track 10 — the goal here is that you can describe it from memory.Clean up. Destroy the small stack and remove local plan artifacts:
terraform destroy rm -f tfplan plan.jsonVerify:
terraform destroyremoves the RG/VNet/NSG; confirm withaz group list -o table. Nothing here was expensive, but the habit stands.
Independent challenge
Without copying the exercises, take the module-04 network module (or your
module-06 infra config) and add a complete local "pre-merge gate"
script — a single shell script that runs, in order, terraform fmt -check -recursive, terraform init, terraform validate, a terraform test with at least one meaningful plan-based assertion about your
network (e.g. that the number of subnets equals the length of your input
list), and finally terraform plan -out=tfplan — and that exits
non-zero if any stage fails, exactly as CI would. Deliberately introduce
one failure of each kind (a format issue, a bad argument, a wrong CIDR the
test catches) and confirm your script stops at the right stage each time.
This integrates module 04 (modules), module 02 (fmt/validate), module
01 (plan -out), and this module's testing — and is the exact gate you'll
lift into a real pipeline in track 10.
Stuck? One hint
In a shell script, set -e makes the script abort on the first command that
returns non-zero — which is exactly the CI "fail fast" behavior you want, and
which each Terraform sub-command already signals correctly (fmt -check,
validate, and test all exit non-zero on failure). For the test's
subnet-count assertion, length(azurerm_subnet.this) == length(var.subnet_prefixes)
is the kind of condition that proves your count/for_each wiring is
correct without creating anything.
Common mistakes & troubleshooting
- Running
applyon the PR instead ofplan. The PR should only plan (for review); apply belongs on merge. Applying on PR means unreviewed changes hit real infrastructure. - Re-planning at apply time instead of applying a saved plan. If the
merge job runs a fresh
plan+applyrather thanapply tfplan, the applied change can differ from what was reviewed (state or a dependency moved in between). Useplan -out→apply <file>. - Storing a long-lived SP secret in CI. It's the thing most likely to
leak. Prefer OIDC (
ARM_USE_OIDC=true) with no stored secret; you'll set this up in track 10. - Treating
fmt/validateas sufficient quality gates. They catch formatting and syntax, not insecure or wasteful configuration. Addtflintandcheckov/tfsecfor the bugs that actually cost money or open security holes. - Committing
tfplan/plan.json. These can contain sensitive values and are ephemeral CI artifacts..gitignorethem. - Cost pitfall: an
apply-basedterraform testcreates real resources (then destroys them) — a test suite full of apply-tests against AKS-shaped resources bills every run. Keep most testscommand = plan(free), and reserve apply-tests for the few things that genuinely need a live resource. A misconfigured CI that runsapplyon every PR push, rather than only on merge, can also create/leak resources fast.
Checkpoint quiz
Write down your answer to each question before expanding it — checking without attempting first is the single easiest way to fool yourself into thinking you've learned this.
- Why does running Terraform from a pipeline beat running it from your laptop — give two reasons tied to earlier modules.
- List the canonical Terraform pipeline stages in order, and mark which run on a PR versus on merge.
- What's the point of
plan -out=tfplanfollowed byapply tfplaninstead of just runningapply? - How does a pipeline authenticate to Azure with no
az login, and what's the preferred credential-less method? - Distinguish what
fmt,validate,tflint, andcheckoveach catch. - What does a
command = planterraform testcost in Azure resources, and when would you usecommand = applyinstead? -auto-approveis dangerous on your laptop but acceptable in the CI apply job. Why is it acceptable there?- A
checkovrun fails on an NSG rule. What kind of problem is it likely catching thatterraform validatewould have passed?
Show answers
- Any two: the reviewed plan is exactly the applied plan (auditability); no human holds prod credentials (the pipeline uses an SP/OIDC identity); every change is automatically gated and logged. These extend the reviewability/auditability advantages of declarative IaC from module 00.
fmt -check→init→validate→ lint/policy (tflint/checkov) →plan -out=tfplan→apply tfplan. Everything throughplanruns on the PR;applyruns only on merge to main.- It guarantees the change that gets applied is byte-for-byte the plan a reviewer approved — no re-computation that could differ from what was reviewed.
- Via the
ARM_*environment variables the provider reads (client id, tenant, subscription, and either a secret or OIDC). The preferred method is OIDC/workload identity federation (ARM_USE_OIDC=true) with no stored long-lived secret. fmt= formatting only;validate= syntax/reference consistency (no Azure call, no quality judgment);tflint= provider best-practice/lint issues;checkov/tfsec= security/policy misconfigurations.- A
plantest creates nothing (free) — it asserts against the planned result. Usecommand = applyonly when an assertion genuinely needs a live, created resource; it bills because it creates (then destroys) real resources. - Because a human already reviewed and approved the exact plan on the PR, and the job applies that saved plan — the approval is the gate, so a second interactive prompt would be redundant.
- A security/policy misconfiguration (e.g. an NSG rule opening SSH/22 to
*/ the whole internet).validateonly checks that the config is syntactically well-formed, not that it's safe.
Next
08 — Capstone project: combine everything — a multi-module config with remote state provisioning a VNet+NSG and AKS+ACR, variables and outputs used sensibly — then tear it all down and prove nothing's left.