Skip to content

Terraform CI with GitHub Actions: Format, Validate, Plan

Lesson 7 of 8Intermediate13 min readGitHub Actions & CI/CD · Continuous IntegrationVerified: hashicorp/setup-terraform v4, aws-actions/configure-aws-credentials v6, Terraform 1.13.3, August 2026

Terraform CI is where the difference between validating a change and making a change stops being academic. Every other pipeline in this cluster produces an artifact; this one talks to the systems that run your company.

The rule that follows from that is short: CI plans, it does not apply.

The complete workflow is at examples/github-actions/terraform-ci/plan.yml, validated by npm run check:workflows.

A pull request is a proposal. Running terraform apply from a pull request means the proposal mutates production before anyone has approved it — and because Terraform configuration can run arbitrary code through providers and external data sources, an unreviewed change can do more than the diff appears to describe.

Applying belongs on the default branch after merge, behind an environment with required reviewers. That is the subject of deploying Terraform. This page is the pull-request half.

The plan needs to read current infrastructure state to compute a diff, so it needs cloud credentials. It does not need to write anything.

permissions:
contents: read
id-token: write
steps:
- name: Authenticate to AWS
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::ACCOUNT_ID:role/terraform-plan-readonly
aws-region: eu-west-1

What it doesExchanges the workflow's OIDC token for short-lived AWS credentials bound to a read-only role.

Why we run itA stored access key is a long-lived secret that works from anywhere until someone rotates it. An OIDC exchange produces credentials that expire in minutes and that AWS will only issue to workflows matching the trust policy you wrote.

Expected resultCredentials in the environment for subsequent steps, and no secret in the repository to rotate.

id-token: write reads oddly for a read-only job. It does not grant access to AWS. It grants this workflow permission to request a signed identity token from GitHub — a statement of who the workflow is. Whether that statement is worth anything is decided entirely in AWS, by the role’s trust policy. See OIDC for the full model, and AWS OIDC for writing the trust policy.

The role should be a genuinely read-only one, separate from whatever role applies changes. If the plan role can write, then “CI only plans” is a convention rather than a control.

- name: Check formatting
run: terraform fmt -check -recursive -diff
- name: Initialise
run: terraform init -input=false
- name: Validate
run: terraform validate -no-color

fmt -check exits non-zero when any file differs from canonical formatting — unlike Go’s gofmt -l, this one really does fail. -recursive covers subdirectories, which is not the default and is almost always what you want. -diff prints what would change, so the contributor can fix it without guessing.

init -input=false configures the backend and downloads providers. -input=false is essential: without it, a missing backend variable makes Terraform prompt for input, and on a runner with no terminal the job hangs until it times out.

validate checks that the configuration is internally consistent — types, required arguments, references that resolve. It runs after init because it needs the provider schemas. It does not talk to the cloud, so it catches a whole class of errors before credentials are even used.

-no-color throughout: ANSI escape codes in a log that will be pasted into a pull request comment render as garbage.

- name: Plan
id: plan
run: |
terraform plan -input=false -no-color -out=tfplan | tee plan.txt

-out=tfplan saves the plan to a file. Even though this job will not apply it, saving the plan is what makes the output specific: terraform show tfplan renders exactly what was computed, with no risk that a second plan invocation produces something different because the world moved.

That is why the example writes only a filtered summary to the job summary:

- name: Summarise the plan
if: always()
run: |
{
echo "### Terraform plan"
echo ""
echo '```'
tail -c 60000 plan.txt | grep -E '^(Plan:|No changes|Error)' || echo "see the job log"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"

The Plan: 3 to add, 1 to change, 0 to destroy. line is the one reviewers actually read. A to destroy count above zero on a pull request that was supposed to be additive is the single most useful signal this pipeline produces.

tail -c 60000 bounds the input because job summaries have a size limit — 1 MiB per step — and a plan over a large estate can exceed it. Truncating deliberately beats having the summary silently rejected.

Posting the plan as a pull request comment

Section titled “Posting the plan as a pull request comment”

Many teams want the plan on the pull request itself. This is where a Terraform pipeline most often acquires a security hole, so be deliberate:

  • Commenting needs pull-requests: write. On a pull request from a fork, the default token is read-only and cannot comment, by design.
  • The workaround people find is pull_request_target, which runs with a writable token and secrets. If that workflow then checks out the fork’s code and runs terraform init, the fork controls which providers are downloaded and executed — with your cloud credentials in the environment.

The safe pattern is to keep the untrusted work and the privileged work in separate workflows: the pull_request workflow plans and uploads only a sanitised text summary; a separate workflow_run workflow, running from the default branch’s trusted code, downloads that summary and posts it.

For a private repository with no fork contributions, the simpler single-workflow approach is reasonable — but state that assumption in a comment in the file, because it stops being true the day the repository is opened up.

- uses: hashicorp/setup-terraform@v4
with:
terraform_version: "1.13.3"

Pin it, and quote it. Unpinned, a Terraform release can change plan output or state format between two runs of the same commit — and a state file written by a newer Terraform cannot be read by an older one, which turns an accidental upgrade into an outage for everyone still on the old version.

Quoting matters for the usual YAML reason: 1.13 unquoted is a float, and 1.10 would become 1.1.

on:
pull_request:
branches: [main]
paths: ['infra/**', '.github/workflows/terraform-ci.yml']

paths keeps the job off pull requests that do not touch infrastructure. Including the workflow file itself in the list is the detail people forget — without it, a change to the pipeline is not tested by the pipeline.

Be aware of the interaction with branch protection: if the check is required and paths prevents it from running, the pull request can wait forever for a check that will never report. The usual answer is a small always-running job that reports success when the paths did not match.

Hardcoding the backend in the configuration works until the same code needs to run against more than one state. Partial configuration moves the varying parts to init time:

terraform {
backend "s3" {}
}
- name: Initialise
run: |
terraform init -input=false \
-backend-config="bucket=${STATE_BUCKET}" \
-backend-config="key=${{ matrix.stack }}/terraform.tfstate" \
-backend-config="region=eu-west-1"
env:
STATE_BUCKET: ${{ vars.TF_STATE_BUCKET }}

-input=false is doing real work here. With a partial backend and a missing value, Terraform prompts for it interactively — and on a runner with no terminal that means the job sits until it hits its timeout, with a log that simply stops. A job that “hangs at init” is almost always this.

The bucket name comes from a repository variable rather than a secret. It is configuration, not a credential, and keeping it visible in logs makes diagnosing a wrong-state incident much faster.

Terraform has a native test framework, and it runs in CI like any other test suite:

{/* tests/naming.tftest.hcl */}
run "bucket_name_is_prefixed" {
command = plan
variables {
environment = "staging"
}
assert {
condition = startswith(aws_s3_bucket.assets.bucket, "acme-staging-")
error_message = "bucket name must carry the environment prefix"
}
}
- name: Run Terraform tests
run: terraform test

command = plan evaluates the configuration without creating anything, so these tests need only the read access the plan job already has. They are the right place for assertions about your module logic — naming conventions, conditional resource creation, the values a module computes from its inputs — which are exactly the things that break silently when someone edits a for_each expression.

command = apply creates real infrastructure and destroys it afterwards. It genuinely tests behaviour, and it needs write credentials, so it belongs in a job targeting a disposable sandbox account, not in the pull request pipeline this page describes.

A plan says what will be created. It does not say what it will cost, and “we did not realise that instance type was billed hourly” is a recurring category of incident.

- name: Estimate cost
run: |
infracost breakdown --path tfplan --format json --out-file cost.json
infracost output --path cost.json --format github-comment --out-file cost.md
cat cost.md >> "$GITHUB_STEP_SUMMARY"

Reporting into the job summary rather than as a pull request comment avoids the fork permission problem entirely — commenting needs pull-requests: write, which a fork pull request’s token does not have, and the workaround people find is the trigger this cluster warns about repeatedly.

Treat the number as information, not a gate. Cost estimates are approximations built from list prices; they do not know about your committed-use discounts, and a threshold that fails builds will fail them for changes that are entirely correct.

A repository holding a dozen independent stacks should not plan all of them on every pull request. Path filters do not scale here for the reason covered in matrix builds — a required check that is skipped never reports, and blocks the pull request forever.

A dynamic matrix over the directories that actually changed is the workable shape:

jobs:
discover:
runs-on: ubuntu-latest
outputs:
stacks: ${{ steps.detect.outputs.stacks }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- id: detect
run: |
stacks="$(git diff --name-only "origin/${GITHUB_BASE_REF}...HEAD" \
| grep '^infra/' | cut -d/ -f2 | sort -u | jq -R . | jq -sc .)"
echo "stacks=${stacks:-[]}" >> "$GITHUB_OUTPUT"
plan:
needs: discover
if: needs.discover.outputs.stacks != '[]'
strategy:
fail-fast: false
matrix:
stack: ${{ fromJSON(needs.discover.outputs.stacks) }}
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v7
- name: Plan
run: terraform plan -input=false -no-color -out=tfplan
working-directory: ./infra/${{ matrix.stack }}

fetch-depth: 0 is required — the default checkout is shallow and there is nothing to diff against. The if: guard handles the empty case, and an aggregating job should be what branch protection requires, since the matrix leg names change with the stacks.

Everything on this page assumes the pull request pipeline cannot change infrastructure. That assumption is worth verifying rather than trusting, because it is enforced by an IAM policy someone wrote once:

  1. Read the plan role’s policy and confirm it grants no write actions beyond state locking.

  2. Try a destructive plan deliberately in a sandbox — add a resource removal and confirm the plan job still only plans.

  3. Check CloudTrail (or the equivalent) for any mutating call made by the plan role’s session name. There should be none except the lock.

  4. Confirm the trust policy on the apply role cannot be satisfied by a pull request — the sub claim on a pull_request event differs from a push or an environment, as AWS OIDC sets out.

A pipeline that “only plans” because no workflow currently calls apply is a convention. A pipeline that only plans because the credentials cannot apply is a control.

validate checks that the configuration is internally consistent. It says nothing about whether the infrastructure it describes is a good idea — a security group open to the world is perfectly valid Terraform.

- name: Scan for misconfiguration
uses: aquasecurity/trivy-action@v0.36.0
with:
scan-type: config
scan-ref: ./infra
format: sarif
output: trivy.sarif
severity: HIGH,CRITICAL
- name: Upload results to code scanning
if: always()
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: trivy.sarif

Config scanning runs against the source, so it needs no cloud credentials and can run on a pull request from a fork. That makes it the one Terraform check that is safe to run on genuinely untrusted contributions.

The findings it produces are policy questions rather than errors — unencrypted storage, public network exposure, missing logging. Publishing to code scanning rather than failing the build gives you history, dismissal with a recorded reason, and annotations on the changed lines, which is a much better fit for findings that sometimes have a legitimate answer of “yes, deliberately”.

For rules specific to your organisation, a policy-as-code tool — OPA/Conftest, or a provider’s own — evaluates the plan JSON rather than the source, which is strictly more capable: it can reason about what the change will actually do, including resource replacements the source does not make obvious.

- name: Convert the plan for policy evaluation
run: terraform show -json tfplan > plan.json
- name: Evaluate policy
run: conftest test plan.json --policy ../policy

Note that plan.json has the same sensitivity as tfplan itself — it embeds resource attributes. Evaluate it in the job and delete it; never upload it.

The single most useful number a Terraform pipeline produces is how many resources the change will destroy. Extracting it deliberately, rather than leaving it in the log, is worth the few lines:

- name: Extract the plan summary
id: summary
run: |
terraform show -json tfplan > plan.json
counts="$(jq -r '
[.resource_changes[]?.change.actions[]?] as $a
| {
create: ([$a[] | select(. == "create")] | length),
update: ([$a[] | select(. == "update")] | length),
delete: ([$a[] | select(. == "delete")] | length)
} | "\(.create) \(.update) \(.delete)"' plan.json)"
read -r create update delete <<< "$counts"
{
echo "### Terraform plan"
echo ""
echo "| Add | Change | Destroy |"
echo "| --- | --- | --- |"
echo "| ${create} | ${update} | ${delete} |"
} >> "$GITHUB_STEP_SUMMARY"
echo "destroy=${delete}" >> "$GITHUB_OUTPUT"
rm -f plan.json
- name: Warn on destructive changes
if: steps.summary.outputs.destroy != '0'
run: echo "::warning::this plan destroys ${{ steps.summary.outputs.destroy }} resource(s)"

The annotation appears at the top of the run and against the pull request, so a reviewer sees it without opening anything. Combined with CODEOWNERS on the directories holding stateful resources, that is a practical approximation of “a destructive change needs a second reviewer” — which GitHub environments cannot express conditionally.

rm -f plan.json matters. A step that writes the plan JSON into the workspace has put sensitive values somewhere a later upload-artifact glob could pick them up.

terraform {
required_version = "~> 1.13.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}

Constraints alone are not enough, because ~> 6.0 resolves to whatever is newest at init time. The lock file is what makes the resolution reproducible:

- name: Verify the lock file is current
run: |
terraform init -input=false
git diff --exit-code .terraform.lock.hcl

.terraform.lock.hcl records the exact provider versions and their checksums, and it must be committed. Without it, two runs of the same commit can use different provider versions — and a provider upgrade can change plan output, or in the worst case change what a resource does.

Module versions need the same discipline. A module sourced from a git branch is the infrastructure equivalent of uses: action@main — pin a tag or a commit, for the reasons in pinning actions.

Most repositories manage more than one environment, and the two common layouts have different CI shapes.

Separate directoriesinfra/staging and infra/production, each with its own backend configuration. Verbose, and each environment’s state and configuration are independent, which makes it very hard to apply to the wrong one. This is the safer default.

strategy:
fail-fast: false
matrix:
environment: [staging, production]
steps:
- name: Plan
run: terraform plan -input=false -no-color -out=tfplan
working-directory: ./infra/${{ matrix.environment }}

Terraform workspaces — one configuration, several states selected at run time. Less duplication, and the failure mode is worse: a workflow that forgets to select the workspace plans against default, and a workflow that selects the wrong one plans against the wrong environment with no obvious signal.

If you use workspaces, assert on the selection rather than trusting it:

- run: |
terraform workspace select "${ENVIRONMENT}"
actual="$(terraform workspace show)"
[ "$actual" = "$ENVIRONMENT" ] || { echo "::error::wrong workspace: ${actual}"; exit 1; }
env:
ENVIRONMENT: ${{ matrix.environment }}

Either way, each environment needs its own IAM role with its own trust condition, so the staging pipeline cannot reach production even if the directory or workspace is wrong. That is the control; everything above is defence in depth around it. See AWS OIDC.

  1. Copy examples/github-actions/terraform-ci/plan.yml into a repository with Terraform under infra/, adjusting the role ARN and region to placeholders you control.

  2. Open a pull request that adds a resource. Confirm the job summary shows a Plan: line with a non-zero add count.

  3. Deliberately misformat a .tf file and push. Confirm fmt -check fails and that -diff shows what to change.

  4. Introduce a type error — a string where a number is required — and confirm validate catches it without any cloud call.

  5. Open a pull request that removes a resource. Confirm the summary reports to destroy and think about who on your team should be required to review that.

GitHub Actions Security ChecklistAudit your workflows against the failure modes that actually cause incidents. Free and complete.

Skip the boilerplate — the Professional Toolkit has five production-ready workflow templates.