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.
Why apply does not belong in CI
Section titled “Why apply does not belong in CI”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.
Read-only credentials, obtained per run
Section titled “Read-only credentials, obtained per run”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-1What 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.
The three checks
Section titled “The three checks”- name: Check formatting run: terraform fmt -check -recursive -diff
- name: Initialise run: terraform init -input=false
- name: Validate run: terraform validate -no-colorfmt -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.
Running the plan
Section titled “Running the plan”- 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 runsterraform 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.
Pinning the Terraform version
Section titled “Pinning the Terraform version”- 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.
Scoping the trigger
Section titled “Scoping the trigger”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.
Backend configuration in CI
Section titled “Backend configuration in CI”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.
Testing the configuration itself
Section titled “Testing the configuration itself”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 testcommand = 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.
Estimating the cost of a change
Section titled “Estimating the cost of a change”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.
Many stacks in one repository
Section titled “Many stacks in one repository”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.
Keeping the CI role honest
Section titled “Keeping the CI role honest”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:
-
Read the plan role’s policy and confirm it grants no write actions beyond state locking.
-
Try a destructive plan deliberately in a sandbox — add a resource removal and confirm the plan job still only plans.
-
Check CloudTrail (or the equivalent) for any mutating call made by the plan role’s session name. There should be none except the lock.
-
Confirm the trust policy on the apply role cannot be satisfied by a pull request — the
subclaim on apull_requestevent 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.
Static analysis of the configuration
Section titled “Static analysis of the configuration”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.sarifConfig 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 ../policyNote 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.
Surfacing the destroy count
Section titled “Surfacing the destroy count”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.
Provider and module versions
Section titled “Provider and module versions”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.
Multiple environments and workspaces
Section titled “Multiple environments and workspaces”Most repositories manage more than one environment, and the two common layouts have different CI shapes.
Separate directories — infra/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.
Exercise
Section titled “Exercise”-
Copy
examples/github-actions/terraform-ci/plan.ymlinto a repository with Terraform underinfra/, adjusting the role ARN and region to placeholders you control. -
Open a pull request that adds a resource. Confirm the job summary shows a
Plan:line with a non-zero add count. -
Deliberately misformat a
.tffile and push. Confirmfmt -checkfails and that-diffshows what to change. -
Introduce a type error — a string where a number is required — and confirm
validatecatches it without any cloud call. -
Open a pull request that removes a resource. Confirm the summary reports
to destroyand think about who on your team should be required to review that.
Then what?
Section titled “Then what?”Skip the boilerplate — the Professional Toolkit has five production-ready workflow templates.