100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
Infrastructure as Code — Terraform & Ansible
60 minintermediate

Practice — Add Checkov and tflint to a GitHub Actions PR Workflow

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.

Analogy🏏Cricket
🏏 Think of it like cricket: You are designing the BCCI's standard cricket ground specification module — a parameterisable blueprint that can be instantiated for any venue in the country without redrawing it from scratch. The blueprint fixes the non-negotiables every certified ground shares: a boundary perimeter (the VPC and its CIDR), spectator zones with public access (public subnets with an Internet Gateway route), and restricted player-and-officials zones behind accreditation checks (private subnets routing through NAT). What varies per venue arrives as parameters: how many stands to build (var.azs and subnet counts), whether this is a full international stadium or a modest district ground (var.single_nat_gateway as the cost lever — one shared service corridor instead of one per stand), and the venue's name and signage (tags). Wankhede and a Ranchi district ground are instantiated from the same drawing with different inputs — and when the board later improves the blueprint (adds flow logs, tightens NACLs), every venue inherits the improvement on its next renovation (module version bump) instead of each ground hand-patching its own architecture. That is the entire economics of module authorship: design once, rigorously, then stamp out consistent grounds forever.

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

bash
#!/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

yaml
# .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

bash
#!/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 comment
Analogy🏏Cricket
🏏 Think of it like cricket: Testing your quality gate by deliberately committing bad code is the umpire-review equivalent of the pre-match technology check — the third umpire deliberately runs a known no-ball clip through the replay system to confirm it actually flags the front-foot fault before the match relies on it. A CI security gate that has only ever seen clean code is an unproven safety system: maybe it works, or maybe a path filter is subtly wrong, a step's exit code is being swallowed, or the scanner is silently scanning an empty directory — all of which produce exactly the same green tick as genuine security. You only learn the difference by bowling a deliberate no-ball: commit an unencrypted S3 bucket and confirm Checkov goes red; commit a 't2.mega' instance type and confirm tflint blocks the merge; commit misformatted HCL and watch fmt fail. Each intentional failure proves one specific tripwire is connected. Production incident reviews are full of gates that were installed, went green for a year, and turned out to have been checking nothing — the fielding side that never once tested whether the review system was plugged in until the World Cup final.

Extension 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.
Lesson 13 of 33
0% complete