Infrastructure as code needs CI/CD just like application code. Automated terraform plan on every PR. Automatic apply on merge. Security scanning before deployment. In this lesson, you'll build a production-grade Terraform pipeline that your whole team can rely on.

1. Learning Objectives

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

  • Build complete Terraform CI/CD pipelines
  • Implement plan on PR and apply on merge
  • Manage Terraform state in pipelines
  • Add security scanning to Terraform code
  • Implement approval gates for production
  • Use Terragrunt for multi-environment management

2. Why This Matters

Real-world scenario: Your team manages infrastructure across dev, staging, and production. Manual terraform apply leads to mistakes, untracked changes, and no audit trail. CI/CD brings the same rigor to infrastructure as application code.

3. Core Concepts

GitHub Actions Terraform Pipeline

# .github/workflows/terraform.yml
name: Terraform CI/CD

on:
  pull_request:
    paths:
      - 'terraform/**'
  push:
    branches: [main]
    paths:
      - 'terraform/**'
  workflow_dispatch:
    inputs:
      environment:
        description: 'Environment to deploy'
        required: true
        default: 'dev'
        type: choice
        options:
          - dev
          - staging
          - production

env:
  TF_VERSION: 1.6.0
  WORKING_DIR: ./terraform

jobs:
  terraform-fmt:
    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: ${{ env.WORKING_DIR }}

  terraform-validate:
    runs-on: ubuntu-latest
    needs: terraform-fmt
    steps:
      - uses: actions/checkout@v4
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v2
      - name: Terraform Init
        run: terraform init -backend=false
        working-directory: ${{ env.WORKING_DIR }}
      - name: Terraform Validate
        run: terraform validate
        working-directory: ${{ env.WORKING_DIR }}

  terraform-plan:
    runs-on: ubuntu-latest
    needs: terraform-validate
    if: github.event_name == 'pull_request'
    steps:
      - uses: actions/checkout@v4
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v2
      - name: Configure AWS
        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 }}
        continue-on-error: true
      - name: Comment Plan on PR
        uses: actions/github-script@v7
        env:
          PLAN: ${{ steps.plan.outputs.stdout }}
        with:
          script: |
            const body = `## Terraform Plan
            <details><summary>Show Plan</summary>
            \`\`\`
            ${process.env.PLAN}
            \`\`\`
            </details>`;
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: body
            });
      - name: Plan Status
        if: steps.plan.outcome == 'failure'
        run: exit 1

  terraform-apply:
    runs-on: ubuntu-latest
    needs: terraform-plan
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    environment: production
    steps:
      - uses: actions/checkout@v4
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v2
      - name: Configure AWS
        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
        run: terraform apply -auto-approve
        working-directory: ${{ env.WORKING_DIR }}
      - name: Notify Slack
        uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {
              "text": "✅ Terraform apply completed: ${{ github.sha }}"
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
Complete Terraform CI/CD pipeline

Multi-Environment Pipeline

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

on:
  push:
    branches: [main]
    paths:
      - 'terraform/**'
  workflow_dispatch:
    inputs:
      environment:
        description: 'Environment'
        required: true
        type: environment

jobs:
  terraform-dev:
    runs-on: ubuntu-latest
    environment: dev
    env:
      TF_WORKSPACE: dev
    steps:
      - uses: actions/checkout@v4
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v2
      - name: Configure AWS
        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 -backend-config=environments/${{ env.TF_WORKSPACE }}/backend.tfvars
        working-directory: ./terraform
      - name: Terraform Workspace
        run: terraform workspace select ${{ env.TF_WORKSPACE }} || terraform workspace new ${{ env.TF_WORKSPACE }}
        working-directory: ./terraform
      - name: Terraform Apply
        run: terraform apply -auto-approve -var-file=environments/${{ env.TF_WORKSPACE }}/terraform.tfvars
        working-directory: ./terraform

  terraform-staging:
    runs-on: ubuntu-latest
    environment: staging
    needs: terraform-dev
    env:
      TF_WORKSPACE: staging
    steps:
      - uses: actions/checkout@v4
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v2
      - name: Configure AWS
        uses: aws-actions/configure-aws-credentials@v4
      - name: Terraform Init
        run: terraform init -backend-config=environments/${{ env.TF_WORKSPACE }}/backend.tfvars
        working-directory: ./terraform
      - name: Terraform Workspace
        run: terraform workspace select ${{ env.TF_WORKSPACE }} || terraform workspace new ${{ env.TF_WORKSPACE }}
        working-directory: ./terraform
      - name: Terraform Plan
        id: plan
        run: terraform plan -no-color -var-file=environments/${{ env.TF_WORKSPACE }}/terraform.tfvars
        working-directory: ./terraform
      - name: Upload Plan
        uses: actions/upload-artifact@v4
        with:
          name: staging-plan
          path: terraform/tfplan

  terraform-production:
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://myapp.example.com
    needs: terraform-staging
    env:
      TF_WORKSPACE: production
    steps:
      - uses: actions/checkout@v4
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v2
      - name: Configure AWS
        uses: aws-actions/configure-aws-credentials@v4
      - name: Terraform Init
        run: terraform init -backend-config=environments/${{ env.TF_WORKSPACE }}/backend.tfvars
        working-directory: ./terraform
      - name: Terraform Workspace
        run: terraform workspace select ${{ env.TF_WORKSPACE }} || terraform workspace new ${{ env.TF_WORKSPACE }}
        working-directory: ./terraform
      - name: Terraform Apply
        run: terraform apply -auto-approve -var-file=environments/${{ env.TF_WORKSPACE }}/terraform.tfvars
        working-directory: ./terraform
Multi-environment Terraform pipeline

Terragrunt Integration

# terragrunt.hcl - Root configuration
terraform {
  source = "git::git@github.com:mycompany/terraform-modules.git//vpc?ref=v1.0.0"
}

inputs = {
  environment = "${get_env("ENVIRONMENT", "dev")}"
}

# environments/dev/terragrunt.hcl
include "root" {
  path = find_in_parent_folders()
}

inputs = {
  vpc_cidr = "10.0.0.0/16"
  environment = "dev"
  instance_type = "t3.micro"
}

# environments/prod/terragrunt.hcl
include "root" {
  path = find_in_parent_folders()
}

inputs = {
  vpc_cidr = "10.0.0.0/16"
  environment = "production"
  instance_type = "t3.medium"
}
Terragrunt for DRY infrastructure
# .github/workflows/terragrunt.yml
name: Terragrunt CI/CD

on:
  pull_request:
    paths:
      - 'environments/**'
  push:
    branches: [main]
    paths:
      - 'environments/**'

jobs:
  terragrunt-plan:
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request'
    steps:
      - uses: actions/checkout@v4
      - name: Install Terragrunt
        run: |
          curl -Lo terragrunt https://github.com/gruntwork-io/terragrunt/releases/download/v0.54.0/terragrunt_linux_amd64
          chmod +x terragrunt
          sudo mv terragrunt /usr/local/bin/
      - name: Run Terragrunt Plan
        run: |
          for env in dev staging; do
            cd environments/$env
            terragrunt plan -out=tfplan
            cd ../..
          done

  terragrunt-apply:
    runs-on: ubuntu-latest
    if: github.event_name == 'push'
    environment: production
    steps:
      - uses: actions/checkout@v4
      - name: Install Terragrunt
        run: |
          curl -Lo terragrunt https://github.com/gruntwork-io/terragrunt/releases/download/v0.54.0/terragrunt_linux_amd64
          chmod +x terragrunt
          sudo mv terragrunt /usr/local/bin/
      - name: Run Terragrunt Apply
        run: |
          for env in dev staging production; do
            cd environments/$env
            terragrunt apply -auto-approve
            cd ../..
          done
Terragrunt CI/CD pipeline

4. Complete Project: Production Pipeline

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

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

env:
  TF_VERSION: 1.6.0

jobs:
  checkov:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run Checkov
        uses: bridgecrewio/checkov-action@master
        with:
          directory: ./terraform
          framework: terraform
          output_format: cli
          soft_fail: false

  tfsec:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run tfsec
        uses: aquasecurity/tfsec-sarif-action@v0.1.4
        with:
          sarif_file: tfsec.sarif
          working_directory: ./terraform
      - name: Upload SARIF
        uses: github/codeql-action/upload-sarif@v2
        with:
          sarif_file: tfsec.sarif

  infracost:
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request'
    steps:
      - uses: actions/checkout@v4
      - 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: Comment Infracost
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const costReport = fs.readFileSync('infracost.txt', 'utf8');
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: `## Cost Estimate\n\`\`\`\n${costReport}\n\`\`\``
            });

  plan:
    runs-on: ubuntu-latest
    needs: [checkov, tfsec]
    if: github.event_name == 'pull_request'
    steps:
      - uses: actions/checkout@v4
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v2
      - name: Configure AWS
        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: Terraform Plan
        id: plan
        run: terraform plan -no-color
        working-directory: ./terraform
      - name: Comment Plan
        uses: actions/github-script@v7
        env:
          PLAN: ${{ steps.plan.outputs.stdout }}
        with:
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: `## Terraform Plan\n\`\`\`\n${process.env.PLAN}\n\`\`\``
            });

  apply:
    runs-on: ubuntu-latest
    needs: plan
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    environment:
      name: production
      url: https://myapp.example.com
    steps:
      - uses: actions/checkout@v4
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v2
      - name: Configure AWS
        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: Terraform Apply
        id: apply
        run: terraform apply -auto-approve
        working-directory: ./terraform
      - name: Notify
        if: success()
        uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {
              "text": "✅ Production infrastructure updated"
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
      - name: Notify on Failure
        if: failure()
        uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {
              "text": "❌ Production apply failed!"
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
Complete production Terraform pipeline

5. Common Errors & Solutions

6. Summary Checklist

7. Next Steps

Next category: AWS Cloud - Cloud Computing Fundamentals

Gataya Med

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

Comments (0)

Sarah Chen February 22, 2025

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

Reply

Leave a Comment