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

GitHub Actions Pipeline — Lint and Plan on PR, Apply on Merge

What You'll Build

You will implement the complete GitHub Actions CI/CD pipeline for the capstone project. The pipeline has two workflows: 'terraform-ci.yml' (runs on every PR touching .tf files: fmt check → validate → tflint → Checkov → plan with PR comment) and 'deploy.yml' (runs on merge to main: re-runs quality gates → applies Terraform → waits for SSM → runs Ansible → verifies idempotency). This pipeline implements the full plan-review-apply workflow with all quality gates enforced, security scanning required, and human approval required for production applies via GitHub Environments.

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.
yaml
# .github/workflows/terraform-ci.yml — PR quality gate
name: Terraform CI
on:
  pull_request:
    paths: ['environments/**/*.tf', 'modules/**/*.tf', '.tflint.hcl']

permissions:
  contents: read
  id-token: write
  pull-requests: write
  security-events: write

env:
  TF_VERSION: '1.6.4'
  TF_WORKDIR: 'environments/production'

jobs:
  fmt:
    name: 'Stage 1 — fmt'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with: { terraform_version: '${{ env.TF_VERSION }}' }
      - run: terraform fmt -check -recursive -no-color

  validate:
    name: 'Stage 2 — validate'
    runs-on: ubuntu-latest
    needs: fmt
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with: { terraform_version: '${{ env.TF_VERSION }}' }
      - run: terraform init -backend=false -input=false
        working-directory: ${{ env.TF_WORKDIR }}
      - run: terraform validate -no-color
        working-directory: ${{ env.TF_WORKDIR }}

  tflint:
    name: 'Stage 3 — tflint'
    runs-on: ubuntu-latest
    needs: validate
    steps:
      - uses: actions/checkout@v4
      - uses: terraform-linters/setup-tflint@v4
        with: { tflint_version: v0.50.0 }
      - run: tflint --init && tflint --format compact --chdir=${{ env.TF_WORKDIR }}

  checkov:
    name: 'Stage 4 — Checkov'
    runs-on: ubuntu-latest
    needs: validate
    steps:
      - uses: actions/checkout@v4
      - uses: bridgecrewio/checkov-action@master
        with:
          directory: .
          framework: terraform
          severity: HIGH
          soft_fail: false

  plan:
    name: 'Stage 5 — Plan + PR Comment'
    runs-on: ubuntu-latest
    needs: [tflint, checkov]
    if: github.event_name == 'pull_request'
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/cricket-github-actions-terraform
          aws-region: ap-south-1
      - uses: hashicorp/setup-terraform@v3
        with: { terraform_version: '${{ env.TF_VERSION }}' }
      - name: Plan
        id: plan
        working-directory: ${{ env.TF_WORKDIR }}
        run: |
          terraform init -input=false
          terraform plan -input=false -no-color -out=plan.tfplan -detailed-exitcode 2>&1 | tee plan.txt
          echo "exit_code=${PIPESTATUS[0]}" >> $GITHUB_OUTPUT
        env:
          TF_VAR_db_password: ${{ secrets.DB_PASSWORD }}
        continue-on-error: true
      - name: Post plan comment
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const plan = fs.readFileSync('${{ env.TF_WORKDIR }}/plan.txt', 'utf8');
            const ec = '${{ steps.plan.outputs.exit_code }}';
            const icon = ec==='0' ? '✅' : ec==='2' ? '⚠️' : '❌';
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner, repo: context.repo.repo,
              body: `## ${icon} Terraform Plan\n<details><summary>Output</summary>\n\n\`\`\`\n${plan.slice(0,60000)}\n\`\`\`\n</details>`
            });
yaml
# .github/workflows/deploy.yml — Main branch apply
name: Deploy to Production
on:
  push:
    branches: [main]
    paths: ['environments/**/*.tf', 'ansible/**']

permissions:
  contents: read
  id-token: write

jobs:
  terraform-apply:
    name: Terraform Apply
    runs-on: ubuntu-latest
    environment: production  # GitHub Environment — requires approval
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/cricket-github-actions-terraform
          aws-region: ap-south-1
      - uses: hashicorp/setup-terraform@v3
        with: { terraform_version: '1.6.4' }
      - name: Apply
        working-directory: environments/production
        run: |
          terraform init -input=false
          terraform apply -input=false -auto-approve -no-color
        env:
          TF_VAR_db_password: ${{ secrets.DB_PASSWORD }}
      - name: Get instance IDs
        id: instances
        working-directory: environments/production
        run: |
          echo "ids=$(terraform output -json instance_ids | jq -r '.[]' | tr '\n' ',')" >> $GITHUB_OUTPUT

  wait-for-ssm:
    name: Wait for SSM
    runs-on: ubuntu-latest
    needs: terraform-apply
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/cricket-github-actions-terraform
          aws-region: ap-south-1
      - name: Wait
        run: |
          for ID in $(echo '${{ needs.terraform-apply.outputs.ids }}' | tr ',' ' '); do
            timeout 300 bash -c "until aws ssm describe-instance-information --filters Key=InstanceIds,Values=${ID} --query 'InstanceInformationList[0].PingStatus' --output text 2>/dev/null | grep -q Online; do sleep 10; done"
            echo "${ID} online"
          done

  ansible-configure:
    name: Ansible Configure
    runs-on: ubuntu-latest
    needs: wait-for-ssm
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/cricket-github-actions-deploy
          aws-region: ap-south-1
      - run: pip install ansible boto3 botocore && ansible-galaxy collection install amazon.aws community.aws && ansible-galaxy install -r ansible/requirements.yml --roles-path ansible/vendor/roles
      - name: Write vault password
        run: echo '${{ secrets.ANSIBLE_VAULT_PASSWORD }}' > /tmp/vp && chmod 600 /tmp/vp
      - name: Run playbook
        run: ansible-playbook -i ansible/inventory/aws_ec2.yml --vault-password-file /tmp/vp ansible/site.yml
      - name: Idempotency check
        run: |
          RESULT=$(ansible-playbook -i ansible/inventory/aws_ec2.yml --vault-password-file /tmp/vp ansible/site.yml 2>&1)
          CHANGED=$(echo "$RESULT" | grep -o 'changed=[0-9]*' | awk -F= '{sum+=$2}END{print sum+0}')
          echo "Changed on re-run: ${CHANGED}"
          [[ $CHANGED -eq 0 ]] && echo 'PASS: Idempotent' || { echo 'FAIL: Not idempotent'; exit 1; }
      - if: always()
        run: rm -f /tmp/vp
Analogy🏏Cricket
🏏 Think of it like cricket: Notice what the composition in main.tf actually does with the modules: nothing is built twice. The VPC module you authored in M2 is instantiated here unchanged — the rehearsed powerplay routine executed again in a bigger match, not re-invented for it — and every new component plugs into its published outputs: the ALB takes module.vpc.public_subnet_ids the way the fielding plan takes the ground dimensions as given, the ASG takes the private subnet IDs, the RDS subnet group takes the database tier's. That chain of references IS the architecture — Terraform reads it and derives the entire build order (the graph knows subnets precede the ALB, and the ALB's target group precedes the ASG that registers into it) with no explicit sequencing from you, the way a competent operations team derives the setup schedule from the venue drawings rather than being told step by step. The production patterns are non-negotiable for the same reasons their cricket counterparts are: Multi-AZ RDS is the synchronised duplicate record room in a second building (a lost AZ loses no data), create_before_destroy on the launch template is the replacement keeper drilled before the incumbent leaves (no capacity gap during updates), and prevent_destroy on the database is heritage protection on the trophy room — the one demolition that must never be a side effect of a routine renovation.
  • The five-stage CI pipeline enforces quality gates in order (each stage depends on the previous) — formatting errors are caught before wasting time on validation, validation before tflint, linting before security scanning, all before the expensive plan with cloud access.
  • Use GitHub Environments ('environment: production') on the apply job to require manual approval — navigate to Settings → Environments in your repository and add protection rules requiring a designated reviewer before apply proceeds.
  • The idempotency check in the ansible-configure job is a mandatory CI gate — if the playbook is not idempotent, the pipeline fails and the deployment is blocked until the non-idempotent task is fixed.
  • Separate 'plan' and 'apply' IAM roles: the plan role has read-only permissions (no resource write access), preventing a compromised CI job from modifying infrastructure during the PR review phase.
Lesson 32 of 33
0% complete