TL;DR
- Terraform security scanning in GitHub Actions catches insecure infrastructure before
terraform apply, when the change is still a pull request. - Trivy
0.61.0can scan Terraform files for misconfigurations without cloud credentials or a live account. - A useful gate fails on high and critical findings, while a separate ignore file documents narrowly scoped exceptions.
- Static scanning is not a replacement for plan review, provider policy, or runtime cloud configuration checks.
A Terraform pull request can pass formatting, type checks, and even terraform validate while still opening a storage bucket to the public internet or allowing SSH from 0.0.0.0/0. Those checks confirm that Terraform can understand the configuration. They do not decide whether the requested infrastructure is safe to deploy.
This article builds a Terraform security scanning gate in GitHub Actions with Trivy 0.61.0, then verifies that an intentionally unsafe resource fails the workflow before it can reach terraform plan or terraform apply.
Note
What you need: Terraform CLI 1.9.8, Docker 27.3.1 or later, Git, and a GitHub repository where you can add workflows. The example uses local Terraform files only, so it does not need cloud credentials.
Why Terraform security scanning in GitHub Actions matters
terraform fmt checks formatting, and terraform validate checks configuration syntax and type constraints. Neither command evaluates common cloud security risks. A configuration can be valid Terraform and still violate the security boundary your team expects.
The Terraform validation documentation describes validation as a syntax and internal-consistency check. Treat it as one layer in the pipeline, not as a security scanner.
| Check | Finds | Does not find |
|---|---|---|
terraform fmt | Formatting drift | Insecure resource settings |
terraform validate | Invalid references and types | Public exposure or weak encryption defaults |
| Trivy config scan | Known IaC misconfigurations | Runtime drift after deployment |
terraform plan review | The proposed resource changes | Every provider-specific security policy |
The failure mode is a green pipeline that proves only that an unsafe request is well-formed. The fix is to put a policy-aware scan before the deployment job and make its failure behavior explicit.
Build an unsafe Terraform fixture
A reproducible fixture makes the gate testable. The following module creates an Amazon S3 bucket with public access enabled and a security group that permits SSH from every IPv4 address. Do not apply this configuration to an AWS account.
Create examples/insecure/main.tf with these resources:
terraform {
required_version = ">= 1.9.8, < 2.0.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.70"
}
}
}
provider "aws" {
region = "us-east-1"
}
resource "aws_s3_bucket" "artifacts" {
bucket = "replace-with-a-unique-bucket-name"
}
resource "aws_s3_bucket_public_access_block" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
block_public_acls = false
block_public_policy = false
ignore_public_acls = true
restrict_public_buckets = false
}
resource "aws_security_group" "admin" {
name = "insecure-admin"
ingress {
description = "SSH from anywhere"
protocol = "tcp"
from_port = 22
to_port = 22
cidr_blocks = ["0.0.0.0/0"]
}
}The example is deliberately unsafe, but it is also syntactically valid. Initialize and validate it locally:
cd examples/insecure
terraform init -backend=false
terraform fmt -check
terraform validateterraform validate should exit successfully. That result demonstrates why syntax validation alone cannot be the security gate.
Add the Trivy workflow
The workflow should scan the repository before any job that can authenticate to a cloud account. Pin the scanner image instead of using a floating latest tag, and keep the workflow permission set minimal.
Create .github/workflows/terraform-security.yml:
name: Terraform security
on:
pull_request:
paths:
- "**/*.tf"
- ".trivyignore"
- ".github/workflows/terraform-security.yml"
push:
branches: [main]
paths:
- "**/*.tf"
- ".trivyignore"
- ".github/workflows/terraform-security.yml"
permissions:
contents: read
jobs:
misconfiguration:
name: Scan Terraform configuration
runs-on: ubuntu-24.04
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Scan infrastructure as code
uses: aquasecurity/trivy-action@0.28.0
with:
scan-type: config
scan-ref: .
trivy-version: "v0.61.0"
severity: HIGH,CRITICAL
exit-code: "1"
ignore-unfixed: truescan-type: config tells Trivy to inspect configuration rather than container images. severity limits the gate to findings your team has decided are release-blocking, and exit-code: "1" converts a finding into a failed job.
Run the same scanner locally before opening a pull request. The Docker command uses the pinned image and mounts the repository read-only:
docker run --rm \
-v "$PWD:/workspace:ro" \
-w /workspace \
aquasec/trivy:0.61.0 \
config --severity HIGH,CRITICAL --exit-code 1 .The command should report findings for the public S3 access and unrestricted SSH rule, then exit with status 1. No AWS credentials should be present for this scan.
How to fix it
The scanner identifies risk, but the pull request still needs an engineering fix. Apply the fixes in the same order you would review them in production.
- Block public access on the bucket. Keep the default-deny controls enabled and add an explicit bucket policy only for the identities that need access.
- Replace
0.0.0.0/0on administrative ports with a private network range or a security-group reference. If temporary public access is unavoidable, make it time-bound and monitored. - Run
terraform fmt -check,terraform validate, and the security scan together. A scan that runs only afterterraform applyis a report, not a preventive control. - Review the Terraform plan in a separate job with cloud credentials. Keep credentials out of the static scan job because the scanner does not need them.
The following safer security group rule illustrates the boundary without requiring a real network:
resource "aws_security_group" "admin" {
name = "private-admin"
ingress {
description = "SSH from the operations network"
protocol = "tcp"
from_port = 22
to_port = 22
cidr_blocks = ["10.20.0.0/16"]
security_groups = []
}
}Keep the scanner job independent from the deploy job. That separation follows the same short-lived-credential principle described in the OIDC guide for GitHub Actions: a job should receive only the permissions it needs for its own work.
How to verify the gate blocks unsafe changes
A security gate is useful only when you can prove both failure and success behavior. Run the checks locally, then use the pull request workflow as the final integration test.
Use this sequence from the repository root:
terraform -chdir=examples/insecure init -backend=false
terraform -chdir=examples/insecure fmt -check
terraform -chdir=examples/insecure validate
docker run --rm \
-v "$PWD:/workspace:ro" \
-w /workspace \
aquasec/trivy:0.61.0 \
config --severity HIGH,CRITICAL --exit-code 1 .The first three commands should pass. The Trivy command should fail and print the finding IDs. After replacing the unsafe fixture or adding a narrowly justified exception, rerun the command and confirm it exits with status 0.
If the workflow passes unexpectedly, check that the scanner is reading the directory containing the .tf files, that severity is not excluding the finding, and that exit-code is set to 1. Also confirm that the workflow is not marked continue-on-error.
When this approach breaks down
Static Terraform scanning is an early control, not the entire infrastructure security program.
- A scanner cannot evaluate every value produced by remote state, data sources, or provider APIs. Scan the rendered plan or enforce policy at the deployment boundary for those cases.
- Rules can produce false positives or miss provider-specific behavior. Keep provider policy, code review, and runtime cloud posture checks in the pipeline.
- An ignore file can turn a real vulnerability into a green build. Require an owner, reason, and expiration review for every exception instead of ignoring by broad directory or rule family.
Trivy findings also depend on its bundled rules and version. Pin the version, review upgrades, and treat changes in scanner behavior as a dependency update that deserves a pull request.
Frequently asked questions
Does terraform validate replace a security scanner?
No. Validation checks whether Terraform can parse and type-check the configuration. It does not decide whether a resource is publicly exposed, weakly encrypted, or overly permissive.
Should the scan run on pull requests from forks?
Yes, if the job needs only repository contents and no secrets. Keep cloud credentials and apply permissions in separate jobs, and do not expose those credentials to untrusted pull request code.
How should accepted findings be documented?
Use the narrowest supported exception, explain the compensating control, assign an owner, and set an expiration date. An exception without an expiry becomes an undocumented permanent policy change.

