CI/CD for Terraform: GitHub Actions, OIDC, and Quality Scanning
So far we've run Terraform by hand on our own machine. For production that doesn't hold up: everyone has strong credentials on their laptop, there's no review before apply, and "works on my machine" becomes the catchphrase. Part VII moves Terraform into a pipeline. This article builds a pragmatic GitHub Actions pipeline: automatic plan on pull requests for review, apply on merge, AWS authentication via OIDC with no stored keys, and a series of quality gates that block errors before they reach prod.
Goal
Understand the structure of a Terraform pipeline (plan on PR, apply on merge), authenticate via OIDC instead of access keys, and bolt on the fmt/validate/tflint/Trivy/Checkov scanners with an understanding of what catches what.
Quality gates: run them locally first
Every step in the pipeline is a command you can run on your machine. Start with the two cheapest:
$ terraform fmt -check -recursive
(OK)
$ terraform validate
Success! The configuration is valid.
fmt -check catches off-standard formatting, validate catches syntax errors and bad references without calling the API. Next is tflint — a linter dedicated to Terraform, catching errors validate misses (wrong enum values, nonexistent instance type names, naming conventions):
$ tflint
(no warnings — code clean at the tflint level)
Security scanning: Trivy and Checkov
validate and tflint check correctness, not safety. A security group that opens port 80 to the whole Internet is perfectly valid HCL, but it can be a hole. This is the job of misconfiguration scanners.
One important note on tool choice: tfsec used to be the popular option, but it is deprecated and was merged into Trivy. Older articles online still cite tfsec; the current way is to use trivy config. Scan the actual configuration from Article 14 (VPC module + EC2):
$ trivy config ./
Tests: 5 (SUCCESSES: 0, FAILURES: 5)
Failures: 5 (UNKNOWN: 0, LOW: 2, MEDIUM: 0, HIGH: 2, CRITICAL: 1)
AWS-0104 (CRITICAL): Security group rule allows unrestricted egress to any IP address.
AWS-0131 (HIGH): Root block device is not encrypted.
AWS-0028 (HIGH): Instance does not require IMDS access to require a token.
AWS-0178 (MEDIUM): VPC does not have VPC Flow Logs enabled.
AWS-0124 (LOW): Security group rule does not have a description.
Trivy flags five problems in the code we thought "worked fine" in Article 14: egress open to everything, root volume unencrypted, IMDS not requiring a token, VPC without flow logs enabled. These are all real, each with an AVD code to look up how to fix it.
Checkov (by Bridgecrew) is a second tool, a different rule set, so it usually catches more than or different from Trivy — run both for broad coverage:
$ checkov -d .
Passed checks: 7, Failed checks: 8, Skipped checks: 0
CKV_AWS_260: "Ensure no security groups allow ingress from 0.0.0.0:0 to port 80"
CKV_AWS_23: "Ensure every security group and rule has a description"
CKV_AWS_79: "Ensure Instance Metadata Service Version 1 is not enabled"
CKV_AWS_130: "Ensure VPC subnets do not assign public IP by default"
The full quality suite ordered by how "expensive" it is: fmt → validate → tflint (static, fast) → Trivy + Checkov (security) → and at a higher level, policy-as-code (Sentinel/OPA) to block per company policy. Don't use tfsec anymore.
OIDC: authentication with no stored keys
The pipeline needs permission to call AWS. The old way is to store an access key pair in the repo's secrets — but a static key is a risk: it lives forever, a leak is dangerous, and it has to be rotated manually. The current way is OIDC: GitHub Actions issues a short-lived identity token for each run, AWS trusts that token and hands back temporary credentials in exchange. No key is stored anywhere.
The mechanism needs a one-time setup on the AWS side: create an IAM OIDC provider trusting token.actions.githubusercontent.com, then an IAM role with a trust policy that only lets your specific repo assume that role. In the workflow, two pieces make OIDC work:
permissions:
id-token: write # allow the job to fetch an OIDC token
contents: read
# ...
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }} # role that trusts GitHub OIDC
aws-region: ap-southeast-1
permissions: id-token: write is mandatory — without it the job can't get a token. The configure-aws-credentials action exchanges the token for temporary credentials for the specified role. The only secret you need to store is the role's ARN (not really a secret), no more access keys.
Pipeline structure
Put it together into Git's familiar flow: changes go through a pull request, get planned and reviewed, and on merge get applied.
Pull request ──► [quality] fmt/validate/tflint/Trivy/Checkov
│ pass
▼
[plan] OIDC -> terraform plan -> attach to PR for review
│ (reviewer approves, merges)
▼
Push production ─► [quality] ──► [apply] OIDC -> terraform apply
│
GitHub Environment: manual approval gate
Three jobs: quality runs every check gate on every PR; plan runs on PRs and attaches the plan result so the reviewer can see it before merging; apply only runs when pushing to production. The apply job attaches environment: production — GitHub Environments lets you set a manual approval gate, so even after a merge, apply still waits for someone to click approve before touching real infrastructure. This is the last checkpoint for prod.
A worthwhile addition is infracost: it estimates the cost of a change and attaches it to the PR ("this change costs an extra $47/month"), helping the reviewer see the dollar impact before merging. Bolt it into the plan job as another step.
Save the plan to apply exactly what was approved
A detail Article 2 mentioned now becomes important: in CI you should terraform plan -out=tfplan then terraform apply tfplan, so apply executes exactly the plan that was reviewed, not a new plan that may have changed. On a personal machine you can skip it; in a pipeline this is the difference between "apply what was approved" and "apply something else without noticing".
Wrap-up
The Terraform pipeline takes changes through a pull request: the quality job runs fmt/validate/tflint (correctness) then Trivy/Checkov (safety — Trivy replacing the deprecated tfsec), the plan job attaches the plan to the PR for review, the apply job runs on merge with a GitHub Environments approval gate. Authentication via OIDC (id-token: write + configure-aws-credentials with role-to-assume) eliminates static access keys. A real scan of the Article 14 code shows five problems Trivy catches and eight Checkov catches — proof that the quality gates aren't a formality.
The pipeline handles running safely, but how do you know the Terraform code is correct before it runs? Article 19 goes into testing: terraform test (GA since 1.6) with .tftest.hcl files, how to mock the provider so tests don't need real infrastructure, and an introduction to Terratest for deeper integration testing.