What You'll Build
You will build a complete, production-quality GitHub Actions CI workflow for the cricket analytics Terraform repository that runs a five-stage quality gate on every pull request: terraform fmt check (formatting), terraform validate (syntax and schema), tflint (semantic correctness and deprecated usage), Checkov (security scanning), and terraform plan (change preview with output posted as a PR comment). Each stage must pass before the next runs, ensuring that obvious errors are caught early. The plan output is posted to the PR as a comment so reviewers can see the infrastructure impact without running Terraform locally. On merge to main, a separate job runs terraform apply with the saved plan file. This workflow implements the complete plan-review-apply pipeline that is the industry standard for production Terraform operations.
Prerequisites
- A GitHub repository containing the cricket analytics Terraform configuration (from M1 or M2 exercises)
- GitHub Actions enabled on the repository
- AWS OIDC provider configured (from M3 L1 secrets hygiene lesson) — or static AWS credentials in GitHub secrets as an alternative
- tflint and Checkov conceptual understanding from M3 Lessons 2 and 3
- Basic YAML syntax for GitHub Actions workflow files
Step 1 — Repository Structure and .tflint.hcl
#!/bin/bash
# Set up repository structure
mkdir -p .github/workflows
# Create tflint configuration
cat > .tflint.hcl << 'TFLINT'
config {
call_module_type = "local"
force = false
}
plugin "terraform" {
enabled = true
version = "0.5.0"
source = "github.com/terraform-linters/tflint-ruleset-terraform"
}
plugin "aws" {
enabled = true
version = "0.30.0"
source = "github.com/terraform-linters/tflint-ruleset-aws"
deep_check = false # No AWS creds needed for pre-commit
}
rule "terraform_documented_variables" { enabled = true }
rule "terraform_documented_outputs" { enabled = true }
rule "terraform_naming_convention" { enabled = true
variable { format = "snake_case" }
output { format = "snake_case" }
resource { format = "snake_case" }
}
rule "aws_instance_invalid_type" { enabled = true }
rule "aws_instance_previous_type" { enabled = true }
rule "aws_db_instance_invalid_type" { enabled = true }
rule "aws_lambda_function_invalid_runtime" { enabled = true }
TFLINT
# Create Checkov configuration
cat > .checkov.yml << 'CHECKOV'
check:
- CKV_AWS_7 # Lambda: encryption
- CKV_AWS_16 # RDS: encryption
- CKV_AWS_17 # RDS: not publicly accessible
- CKV_AWS_18 # S3: access logging
- CKV_AWS_19 # S3: encryption
- CKV_AWS_20 # S3: not public
- CKV_AWS_23 # RDS: multi-AZ
- CKV_AWS_25 # SG: no unrestricted port 22
- CKV_AWS_57 # S3: versioning
- CKV_AWS_111 # Secrets Manager: rotation
CHECKOV
git add .tflint.hcl .checkov.yml
git commit -m 'ci: add tflint and Checkov configurations'
echo 'Configuration files created'Step 2 — Complete GitHub Actions Workflow
# .github/workflows/terraform-ci.yml
# Complete PR quality gate and main-branch apply pipeline
name: Terraform CI/CD
on:
pull_request:
paths:
- 'environments/**/*.tf'
- 'environments/**/*.tfvars'
- 'modules/**/*.tf'
- '.tflint.hcl'
push:
branches: [main]
paths:
- 'environments/**/*.tf'
- 'environments/**/*.tfvars'
- 'modules/**/*.tf'
# Minimal permissions — only what each job needs
permissions:
contents: read
id-token: write # OIDC credential exchange
pull-requests: write # Post plan comment on PR
security-events: write # Upload SARIF to GitHub Code Scanning
env:
TF_VERSION: '1.6.4'
AWS_REGION: 'ap-south-1'
TF_WORKDIR: 'environments/production'
# ── PR Quality Gate ────────────────────────────────────────────────────────────
jobs:
# Stage 1: Formatting check (fastest — no cloud access needed)
fmt:
name: 'Stage 1 — Terraform Format'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Check formatting
id: fmt
run: terraform fmt -check -recursive -no-color
- name: Show diff on failure
if: failure() && steps.fmt.outcome == 'failure'
run: |
echo 'Files with formatting errors:'
terraform fmt -recursive -check -no-color 2>&1 | grep '##'
echo
echo 'Fix with: terraform fmt -recursive'
# Stage 2: Syntax validation (fast — validates against provider schema)
validate:
name: 'Stage 2 — Terraform Validate'
runs-on: ubuntu-latest
needs: fmt # Only run if fmt passes
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Terraform init (for provider schema)
run: terraform init -backend=false -input=false
working-directory: ${{ env.TF_WORKDIR }}
- name: Validate
run: terraform validate -no-color
working-directory: ${{ env.TF_WORKDIR }}
# Stage 3: tflint — semantic and provider-specific checks
tflint:
name: 'Stage 3 — tflint Linting'
runs-on: ubuntu-latest
needs: validate
steps:
- uses: actions/checkout@v4
- uses: terraform-linters/setup-tflint@v4
with:
tflint_version: v0.50.0
- name: Download tflint plugins
run: tflint --init
- name: Run tflint on all Terraform directories
run: |
FAIL=0
for dir in environments/production modules/vpc modules/database modules/compute; do
if [[ -d "$dir" ]]; then
echo "--- Linting: $dir ---"
tflint --chdir="$dir" --format compact || FAIL=1
fi
done
exit $FAIL
# Stage 4: Checkov — security scanning
checkov:
name: 'Stage 4 — Checkov Security Scan'
runs-on: ubuntu-latest
needs: validate
steps:
- uses: actions/checkout@v4
- name: Run Checkov
uses: bridgecrewio/checkov-action@master
with:
directory: .
framework: terraform
severity: HIGH
output_format: sarif
output_file_path: checkov-results.sarif
soft_fail: false # Fail workflow on HIGH findings
- name: Upload SARIF results
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: checkov-results.sarif
# Stage 5: Terraform plan with PR comment
plan:
name: 'Stage 5 — Terraform Plan'
runs-on: ubuntu-latest
needs: [tflint, checkov] # Require both stages 3 and 4 to pass
if: github.event_name == 'pull_request'
outputs:
plan_exit_code: ${{ steps.plan.outputs.exit_code }}
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/cricket-github-actions-terraform
aws-region: ${{ env.AWS_REGION }}
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Terraform init
run: terraform init -input=false
working-directory: ${{ env.TF_WORKDIR }}
env:
TF_VAR_db_password: ${{ secrets.DB_PASSWORD }}
- name: Terraform plan
id: plan
run: |
terraform plan \
-input=false \
-no-color \
-out=pr.tfplan \
-detailed-exitcode \
2>&1 | tee plan_output.txt
echo "exit_code=${PIPESTATUS[0]}" >> $GITHUB_OUTPUT
working-directory: ${{ env.TF_WORKDIR }}
env:
TF_VAR_db_password: ${{ secrets.DB_PASSWORD }}
continue-on-error: true
- name: Post plan to PR
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const plan = fs.readFileSync('${{ env.TF_WORKDIR }}/plan_output.txt', 'utf8');
const exitCode = '${{ steps.plan.outputs.exit_code }}';
const status = exitCode === '0' ? '✅ No changes' :
exitCode === '2' ? '⚠️ Changes planned' :
'❌ Plan failed';
const body = `## Terraform Plan — ${status}\n\n<details>\n<summary>Click to expand plan output</summary>\n\n\`\`\`hcl\n${plan.slice(0, 60000)}\n\`\`\`\n</details>`;
// Delete previous plan comments
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
for (const comment of comments) {
if (comment.body.startsWith('## Terraform Plan')) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: comment.id,
});
}
}
// Post new plan comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
- name: Fail if plan failed
if: steps.plan.outputs.exit_code == '1'
run: exit 1
- name: Upload plan artifact
uses: actions/upload-artifact@v4
if: steps.plan.outputs.exit_code == '2'
with:
name: terraform-plan
path: ${{ env.TF_WORKDIR }}/pr.tfplan
retention-days: 5
# ── Main branch apply ─────────────────────────────────────────────────────
apply:
name: 'Apply — Main Branch Only'
runs-on: ubuntu-latest
needs: [tflint, checkov]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment: production # Requires environment protection rules approval
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/cricket-github-actions-terraform
aws-region: ${{ env.AWS_REGION }}
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Terraform init
run: terraform init -input=false
working-directory: ${{ env.TF_WORKDIR }}
- name: Terraform plan (re-plan for safety)
run: |
terraform plan -input=false -no-color -out=apply.tfplan
working-directory: ${{ env.TF_WORKDIR }}
env:
TF_VAR_db_password: ${{ secrets.DB_PASSWORD }}
- name: Terraform apply
run: terraform apply -input=false apply.tfplan
working-directory: ${{ env.TF_WORKDIR }}Step 3 — Test the Workflow
#!/bin/bash
# Test the workflow by introducing intentional failures
echo '=== Test 1: Formatting failure ==='
# Deliberately misformat a file
cat >> environments/production/main.tf << 'BAD'
resource "aws_s3_bucket" "test" {
bucket="test-bucket-misformatted"
}
BAD
git checkout -b test/formatting-failure
git add environments/production/main.tf
git commit -m 'test: misformatted file (should fail Stage 1)'
# Create PR and observe GitHub Actions Stage 1 failing
# Then revert:
git checkout main
git branch -D test/formatting-failure
echo
echo '=== Test 2: tflint failure ==='
git checkout -b test/invalid-instance-type
cat >> environments/production/main.tf << 'BAD'
# This resource uses a previous-generation instance type
resource "aws_instance" "legacy" {
ami = data.aws_ami.al2023.id
instance_type = "t2.micro" # tflint: aws_instance_previous_type
}
BAD
git add environments/production/main.tf
git commit -m 'test: previous-gen instance type (should fail Stage 3)'
# PR will show Stage 1+2 pass, Stage 3 fail
git checkout main
git branch -D test/invalid-instance-type
echo
echo '=== Test 3: Checkov failure ==='
git checkout -b test/public-s3
cat >> environments/production/main.tf << 'BAD'
# CKV_AWS_20 will fire on this — public S3 bucket
resource "aws_s3_bucket_acl" "public" {
bucket = aws_s3_bucket.data.id
acl = "public-read"
}
BAD
git add environments/production/main.tf
git commit -m 'test: public S3 ACL (should fail Stage 4)'
# PR will show Stages 1-3 pass, Stage 4 fail
git checkout main
git branch -D test/public-s3
echo
echo '=== Test 4: All stages pass ==='
git checkout -b feature/add-cloudwatch-alarm
# Add a well-formed resource that passes all checks
cat >> environments/production/main.tf << 'GOOD'
resource "aws_cloudwatch_metric_alarm" "api_errors" {
alarm_name = "cricket-api-high-errors"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2
metric_name = "HTTPCode_Target_5XX_Count"
namespace = "AWS/ApplicationELB"
period = 60
statistic = "Sum"
threshold = 100
alarm_description = "API error rate exceeds 100 per minute"
}
GOOD
git add environments/production/main.tf
git commit -m 'feat: add CloudWatch alarm for API errors'
git push origin feature/add-cloudwatch-alarm
# Create PR — all 5 stages should pass
# Plan output appears as a PR commentExtension Challenge: Extend the workflow with three additional quality gates: (1) Add Terratest integration tests as a Stage 6 that runs only on PRs targeting main — it deploys the module to a test AWS account using an OIDC role, validates the deployment, and destroys all test resources; (2) Add cost estimation using Infracost — the Infracost GitHub Action computes the monthly cost delta of the planned changes and posts it alongside the Terraform plan comment on the PR, enabling cost-aware review; (3) Add a CHANGELOG enforcement gate that fails the PR if the CHANGELOG.md was not updated — this enforces documentation discipline and creates a release-ready audit trail of infrastructure changes.
- The five-stage quality gate (fmt, validate, tflint, Checkov, plan) creates a comprehensive pre-merge barrier that catches formatting errors, schema violations, deprecated usage, security misconfigurations, and shows reviewers the exact infrastructure impact — all before any code merges.
- Use 'needs' to create job dependencies — later stages only run if earlier stages pass, preventing wasted time running expensive API calls when simple formatting checks fail.
- The plan comment workflow (delete previous + create new) keeps the PR clean — each new commit replaces the old plan comment rather than accumulating a stack of outdated plans that obscure the current state.
- Upload the plan file as an artifact for the apply job to download — this is safer than re-planning at apply time because it guarantees the exact reviewed plan is what gets applied, even if the infrastructure changes between plan and apply.
- Use GitHub Environments ('environment: production') on the apply job to require manual approval via GitHub's environment protection rules — this adds a human approval gate before production deploys without custom webhook logic.
- Post Checkov results as SARIF to GitHub Code Scanning for inline PR annotations — reviewers see the security finding annotated on the specific line of code rather than in a separate CI log.