Infrastructure changes need the same rigor as application changes. Terraform in CI/CD brings pull request reviews, automated planning, and safe applies to your infrastructure. In this lesson, you'll learn to run terraform plan on every PR, automatically apply on merge, scan for security issues, and enforce policies—all in your pipeline.

1. Learning Objectives

By the end of this lesson, you will be able to:

  • Run terraform plan in CI/CD pipelines
  • Automatically apply infrastructure changes on merge
  • Manage Terraform state in pipelines
  • Scan Terraform code for security issues
  • Enforce policies with Sentinel or OPA
  • Implement drift detection and remediation

2. Why This Matters

Real-world scenario: A developer changes a production security group rule. Without CI/CD, they apply directly—possibly breaking access. With CI/CD, the PR runs terraform plan, a reviewer sees what will change, and automated tests validate the change. Only after approval does the pipeline apply.

3. Core Concepts

Terraform Plan in Pull Requests

# .github/workflows/terraform-pr.yml
name: Terraform PR Check

on:
  pull_request:
    paths:
      - 'terraform/**'
      - '*.tf'

env:
  TF_VERSION: 1.6.0
  WORKING_DIR: ./terraform

jobs:
  terraform-plan:
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write
      contents: read
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v2
        with:
          terraform_version: ${{ env.TF_VERSION }}
          terraform_wrapper: false
      
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1
      
      - name: Terraform Init
        run: terraform init
        working-directory: ${{ env.WORKING_DIR }}
      
      - name: Terraform Validate
        run: terraform validate
        working-directory: ${{ env.WORKING_DIR }}
      
      - name: Terraform Plan
        id: plan
        run: terraform plan -no-color -out=tfplan
        working-directory: ${{ env.WORKING_DIR }}
        continue-on-error: true
      
      - name: Update Pull Request
        uses: actions/github-script@v7
        env:
          PLAN: "terraform\n${{ steps.plan.outputs.stdout }}"
        with:
          script: |
            const output = `#### Terraform Plan 📖\n\n<details><summary>Show Plan</summary>\n\n\`\`\`\n${process.env.PLAN}\n\`\`\`\n</details>`;
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: output
            });
      
      - name: Terraform Plan Status
        if: steps.plan.outcome == 'failure'
        run: exit 1
Terraform plan on pull requests

Terraform Apply on Merge

# .github/workflows/terraform-apply.yml
name: Terraform Apply

on:
  push:
    branches: [main]
    paths:
      - 'terraform/**'
      - '*.tf'

env:
  TF_VERSION: 1.6.0
  WORKING_DIR: ./terraform

jobs:
  terraform-apply:
    runs-on: ubuntu-latest
    environment: production
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v2
        with:
          terraform_version: ${{ env.TF_VERSION }}
      
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1
      
      - name: Terraform Init
        run: terraform init
        working-directory: ${{ env.WORKING_DIR }}
      
      - name: Terraform Plan
        id: plan
        run: terraform plan -no-color -out=tfplan
        working-directory: ${{ env.WORKING_DIR }}
      
      - name: Terraform Apply
        run: terraform apply -auto-approve tfplan
        working-directory: ${{ env.WORKING_DIR }}
      
      - name: Apply Summary
        run: |
          echo "✅ Terraform apply completed"
          echo "Commit: ${{ github.sha }}"
          echo "Author: ${{ github.actor }}"
        working-directory: ${{ env.WORKING_DIR }}
Terraform apply on merge to main

Multi-Environment Deployments

# .github/workflows/terraform-environments.yml
name: Multi-Environment Terraform

on:
  push:
    branches:
      - main
      - develop
    paths:
      - 'terraform/**'

env:
  TF_VERSION: 1.6.0

jobs:
  terraform-plan-dev:
    if: github.ref == 'refs/heads/develop'
    runs-on: ubuntu-latest
    environment: dev
    steps:
      - uses: actions/checkout@v4
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v2
        with:
          terraform_version: ${{ env.TF_VERSION }}
      - name: Terraform Init
        run: terraform init -backend-config=environments/dev/backend.tfvars
        working-directory: ./terraform
      - name: Terraform Apply
        run: terraform apply -auto-approve -var-file=environments/dev/terraform.tfvars
        working-directory: ./terraform

  terraform-plan-staging:
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - uses: actions/checkout@v4
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v2
      - name: Terraform Init
        run: terraform init -backend-config=environments/staging/backend.tfvars
        working-directory: ./terraform
      - name: Terraform Plan
        id: plan
        run: terraform plan -no-color -var-file=environments/staging/terraform.tfvars
        working-directory: ./terraform

  terraform-apply-production:
    needs: terraform-plan-staging
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://myapp.example.com
    steps:
      - uses: actions/checkout@v4
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v2
      - name: Terraform Init
        run: terraform init -backend-config=environments/production/backend.tfvars
        working-directory: ./terraform
      - name: Terraform Apply
        run: terraform apply -auto-approve -var-file=environments/production/terraform.tfvars
        working-directory: ./terraform
Multi-environment Terraform deployments

Security Scanning (tfsec, checkov)

# .github/workflows/terraform-security.yml
name: Terraform Security Scan

on:
  pull_request:
    paths:
      - 'terraform/**'
      - '*.tf'
  push:
    branches: [main]

jobs:
  security-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      # tfsec - static analysis for Terraform
      - name: Run tfsec
        uses: aquasecurity/tfsec-sarif-action@v0.1.4
        with:
          sarif_file: tfsec.sarif
          working_directory: ./terraform
      
      # Checkov - comprehensive scanning
      - name: Run Checkov
        uses: bridgecrewio/checkov-action@master
        with:
          directory: ./terraform
          framework: terraform
          output_format: cli
          soft_fail: false
          quiet: false
      
      # tflint - linting
      - name: Setup tflint
        run: curl -s https://raw.githubusercontent.com/terraform-linters/tflint/master/install_linux.sh | bash
      
      - name: Run tflint
        run: tflint --chdir=./terraform
      
      # Infracost - cost estimation
      - name: Setup Infracost
        uses: infracost/actions/setup@v2
        with:
          api-key: ${{ secrets.INFRACOST_API_KEY }}
      
      - name: Run Infracost
        run: |
          infracost breakdown --path ./terraform \
            --format diff \
            --out-file infracost.txt
      
      - name: Upload security results
        uses: github/codeql-action/upload-sarif@v2
        with:
          sarif_file: tfsec.sarif
Terraform security scanning in CI

Drift Detection

# .github/workflows/terraform-drift.yml
name: Terraform Drift Detection

on:
  schedule:
    - cron: '0 */6 * * *'  # Every 6 hours
  workflow_dispatch:  # Manual trigger

jobs:
  detect-drift:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v2
      
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1
      
      - name: Terraform Init
        run: terraform init
        working-directory: ./terraform
      
      - name: Detect Drift
        id: plan
        run: terraform plan -detailed-exitcode -no-color
        working-directory: ./terraform
        continue-on-error: true
      
      - name: Drift Detected
        if: steps.plan.outcome == 'failure' && steps.plan.exitcode == 2
        run: |
          echo "⚠️ Drift detected in infrastructure!"
          echo "Changes found that aren't in code."
          
          # Send alert
          curl -X POST -H 'Content-type: application/json' \
            --data '{"text":"🚨 Infrastructure drift detected in production!"}' \
            ${{ secrets.SLACK_WEBHOOK }}
      
      - name: Auto-remediate drift (optional)
        if: github.event_name == 'workflow_dispatch'
        run: terraform apply -auto-approve
        working-directory: ./terraform
Infrastructure drift detection

4. Complete Project: Production Terraform Pipeline

# .github/workflows/terraform-production.yml
name: Production Terraform Pipeline

on:
  pull_request:
    paths:
      - 'terraform/**'
  push:
    branches: [main]
    paths:
      - 'terraform/**'
  workflow_dispatch:
    inputs:
      action:
        description: 'Terraform action'
        required: true
        default: 'plan'
        type: choice
        options:
          - plan
          - apply
          - destroy

env:
  TF_VERSION: 1.6.0
  WORKING_DIR: ./terraform/production

concurrency:
  group: terraform-production
  cancel-in-progress: false

jobs:
  terraform-checks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v2
        with:
          terraform_version: ${{ env.TF_VERSION }}
      
      - name: Terraform Format
        run: terraform fmt -check -recursive
        working-directory: ./terraform
      
      - name: Terraform Validate
        run: terraform validate
        working-directory: ${{ env.WORKING_DIR }}
      
      - name: Run tfsec
        uses: aquasecurity/tfsec-sarif-action@v0.1.4
        with:
          sarif_file: tfsec.sarif
          working_directory: ${{ env.WORKING_DIR }}
      
      - name: Run Checkov
        uses: bridgecrewio/checkov-action@master
        with:
          directory: ${{ env.WORKING_DIR }}
          framework: terraform
          soft_fail: true

  terraform-plan:
    needs: terraform-checks
    if: github.event_name == 'pull_request' || (github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'plan')
    runs-on: ubuntu-latest
    environment: production-plan
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v2
      
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1
      
      - name: Terraform Init
        run: terraform init
        working-directory: ${{ env.WORKING_DIR }}
      
      - name: Terraform Plan
        id: plan
        run: terraform plan -no-color
        working-directory: ${{ env.WORKING_DIR }}
      
      - name: Comment Plan on PR
        if: github.event_name == 'pull_request'
        uses: actions/github-script@v7
        with:
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: '## Terraform Plan\n\n```\n${{ steps.plan.outputs.stdout }}\n```'
            });

  terraform-apply:
    needs: terraform-checks
    if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'apply')
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://myapp.example.com
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v2
      
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1
      
      - name: Terraform Init
        run: terraform init
        working-directory: ${{ env.WORKING_DIR }}
      
      - name: Terraform Apply
        id: apply
        run: terraform apply -auto-approve
        working-directory: ${{ env.WORKING_DIR }}
      
      - name: Output Summary
        run: |
          echo "✅ Terraform apply completed successfully"
          echo "Infrastructure changes deployed: ${{ github.sha }}"
        working-directory: ${{ env.WORKING_DIR }}
      
      - name: Notify Slack
        uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {
              "text": "✅ Infrastructure updated: ${{ github.sha }}",
              "blocks": [{
                "type": "section",
                "text": {
                  "type": "mrkdwn",
                  "text": "*Terraform Apply Successful*\nEnvironment: production\nCommit: ${{ github.sha }}\nAuthor: ${{ github.actor }}"
                }
              }]
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}

  terraform-destroy:
    needs: terraform-checks
    if: github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'destroy'
    runs-on: ubuntu-latest
    environment: production-destroy
    steps:
      - uses: actions/checkout@v4
      
      - name: Confirm destroy
        run: |
          echo "⚠️ DESTROY CONFIRMATION REQUIRED"
          echo "This will delete all infrastructure in production!"
          read -p "Type 'DESTROY' to confirm: " confirm
          if [ "$confirm" != "DESTROY" ]; then
            echo "Aborted."
            exit 1
          fi
      
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v2
      
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
      
      - name: Terraform Init
        run: terraform init
        working-directory: ${{ env.WORKING_DIR }}
      
      - name: Terraform Destroy
        run: terraform destroy -auto-approve
        working-directory: ${{ env.WORKING_DIR }}
Complete production Terraform pipeline

5. Common Errors & Solutions

6. Summary Checklist

7. Next Steps

Next lesson: CI/CD Lesson 7.5: Advanced CI/CD Patterns

Gataya Med

DevOps Engineer & Backend Developer. Sharing insights on cloud, automation, and scalable systems.

Comments (0)

Sarah Chen February 16, 2025

This is exactly what I needed! The initContainer approach solved our migration issues completely. Thanks for the detailed guide!

Reply

Leave a Comment