All articles
Tutorials

Terraform Security Scanning in GitHub Actions

Add Terraform security scanning to GitHub Actions with Trivy, fail pull requests on high-risk misconfigurations, and keep approved infrastructure changes reviewable.

Terraform Security Scanning in GitHub Actions cover
7 min read

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.0 can 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.

CheckFindsDoes not find
terraform fmtFormatting driftInsecure resource settings
terraform validateInvalid references and typesPublic exposure or weak encryption defaults
Trivy config scanKnown IaC misconfigurationsRuntime drift after deployment
terraform plan reviewThe proposed resource changesEvery 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:

examples/insecure/main.tf
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 validate

terraform 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:

.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: true

scan-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.

  1. 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.
  2. Replace 0.0.0.0/0 on administrative ports with a private network range or a security-group reference. If temporary public access is unavoidable, make it time-bound and monitored.
  3. Run terraform fmt -check, terraform validate, and the security scan together. A scan that runs only after terraform apply is a report, not a preventive control.
  4. 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:

examples/secure/admin.tf
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.

Frequently asked questions

Share𝕏

Writer

  • Ilyas Rufai

    Technical content writer and DevSecOps specialist focused on cloud-native security and developer experience

Need help with your technical content?

We help B2B SaaS teams turn complex products into clear documentation and content that developers actually use.

Book a call
Terraform Security Scanning in GitHub Actions | Reclear