Skip to main content

Terraform in CI/CD

Running Terraform manually works for small teams, but production infrastructure needs automated pipelines. This lesson covers integrating Terraform into CI/CD for safe, reviewable infrastructure changes.

Why Automate Terraform?

Manual terraform apply has risks:

  • Someone applies from a stale branch
  • Someone applies without the team reviewing the plan
  • Someone forgets to commit their changes
  • Different people have different AWS credentials with different permissions

CI/CD solves all of this by making infrastructure changes go through the same process as code changes: branch, PR, review, merge, deploy.

The GitOps Workflow for Infrastructure

1. Developer creates a branch
2. Makes infrastructure changes in .tf files
3. Opens a pull request
4. CI runs terraform plan and posts the output to the PR
5. Team reviews the plan
6. PR is approved and merged
7. CI runs terraform apply on the main branch

This ensures every infrastructure change is:

  • Reviewed before it's applied
  • Tracked in Git history
  • Reproducible from the code
  • Auditable through PR comments

GitHub Actions Workflow

Plan on Pull Request

# .github/workflows/terraform-plan.yml
name: Terraform Plan

on:
  pull_request:
    paths:
      - 'infrastructure/**'

permissions:
  contents: read
  pull-requests: write

jobs:
  plan:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: infrastructure

    steps:
      - uses: actions/checkout@v4

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.10.0

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: us-east-1

      - name: Terraform Init
        run: terraform init

      - name: Terraform Format Check
        run: terraform fmt -check

      - name: Terraform Validate
        run: terraform validate

      - name: Terraform Plan
        id: plan
        run: terraform plan -no-color -out=tfplan
        continue-on-error: true

      - name: Post Plan to PR
        uses: actions/github-script@v7
        with:
          script: |
            const output = `#### Terraform Plan ๐Ÿ“‹
            \`\`\`
            ${{ steps.plan.outputs.stdout }}
            \`\`\`
            *Pushed by: @${{ github.actor }}*`;

            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: output
            });

      - name: Fail if plan failed
        if: steps.plan.outcome == 'failure'
        run: exit 1

Apply on Merge

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

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

jobs:
  apply:
    runs-on: ubuntu-latest
    environment: production
    defaults:
      run:
        working-directory: infrastructure

    steps:
      - uses: actions/checkout@v4

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.10.0

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: us-east-1

      - name: Terraform Init
        run: terraform init

      - name: Terraform Apply
        run: terraform apply -auto-approve

Key Details

  • OIDC authentication โ€” use role-to-assume instead of storing AWS keys as secrets
  • Environment protection โ€” the environment: production setting requires manual approval in GitHub
  • Path filtering โ€” only trigger when infrastructure files change
  • Plan output in PR โ€” reviewers see exactly what will change

OIDC Authentication

Never store long-lived AWS credentials in GitHub Secrets. Use OIDC (OpenID Connect) instead:

# Create the OIDC provider in AWS
resource "aws_iam_openid_connect_provider" "github" {
  url             = "https://token.actions.githubusercontent.com"
  client_id_list  = ["sts.amazonaws.com"]
  thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}

# Create a role GitHub Actions can assume
resource "aws_iam_role" "github_actions" {
  name = "github-actions-terraform"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Principal = {
        Federated = aws_iam_openid_connect_provider.github.arn
      }
      Action = "sts:AssumeRoleWithWebIdentity"
      Condition = {
        StringEquals = {
          "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
        }
        StringLike = {
          "token.actions.githubusercontent.com:sub" = "repo:my-org/my-repo:*"
        }
      }
    }]
  })
}

This gives GitHub Actions temporary credentials that expire automatically. No secrets to rotate.

Policy as Code

Policy as code validates your Terraform plans against rules before applying.

Open Policy Agent (OPA)

OPA uses the Rego language to define policies:

# policy/no-public-s3.rego
package terraform

deny[msg] {
  resource := input.planned_values.root_module.resources[_]
  resource.type == "aws_s3_bucket_acl"
  resource.values.acl == "public-read"
  msg := sprintf("S3 bucket '%s' must not be public", [resource.name])
}

deny[msg] {
  resource := input.planned_values.root_module.resources[_]
  resource.type == "aws_instance"
  not resource.values.tags.Environment
  msg := sprintf("Instance '%s' must have an Environment tag", [resource.name])
}

Run it in CI:

- name: Terraform Plan (JSON)
  run: |
    terraform plan -out=tfplan
    terraform show -json tfplan > plan.json

- name: OPA Policy Check
  run: |
    opa eval --input plan.json --data policy/ \
      --format pretty "data.terraform.deny"

Sentinel (Terraform Cloud)

Sentinel is HashiCorp's built-in policy framework:

# policies/require-tags.sentinel
import "tfplan/v2" as tfplan

mandatory_tags = ["Environment", "Team", "CostCenter"]

allInstances = filter tfplan.resource_changes as _, rc {
  rc.type is "aws_instance" and
  rc.mode is "managed" and
  (rc.change.actions contains "create" or rc.change.actions contains "update")
}

main = rule {
  all allInstances as _, instance {
    all mandatory_tags as tag {
      instance.change.after.tags contains tag
    }
  }
}

Cost Estimation

Catch expensive infrastructure changes before they're applied:

- name: Infracost
  uses: infracost/actions/setup@v3
  with:
    api-key: ${{ secrets.INFRACOST_API_KEY }}

- name: Generate cost diff
  run: |
    infracost diff --path=infrastructure \
      --format=json --out-file=/tmp/infracost.json

- name: Post cost comment
  uses: infracost/actions/comment@v3
  with:
    path: /tmp/infracost.json
    behavior: update

This posts a cost breakdown to your PR:

Monthly cost will increase by $127.40 (+15%)

+ aws_instance.web        $62.05/mo
+ aws_rds_instance.db     $65.35/mo
~ aws_s3_bucket.assets    $0.00/mo (no change)

Drift Detection

Schedule regular drift detection to catch manual changes:

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

on:
  schedule:
    - cron: '0 8 * * 1'  # Every Monday at 8am

jobs:
  detect-drift:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3

      - name: Terraform Plan
        id: plan
        run: terraform plan -detailed-exitcode -no-color
        continue-on-error: true

      - name: Alert on drift
        if: steps.plan.outputs.exitcode == '2'
        run: |
          echo "Infrastructure drift detected!"
          # Send Slack notification, create GitHub issue, etc.

Exit code 2 from terraform plan -detailed-exitcode means changes are needed โ€” something drifted.

Summary

  • Automate Terraform with GitHub Actions: plan on PR, apply on merge
  • Use OIDC for authentication โ€” never store long-lived cloud credentials
  • Post plan output to PRs so reviewers see exactly what changes
  • Enforce policies with OPA or Sentinel before applying
  • Use Infracost to catch expensive changes in PRs
  • Schedule drift detection to find manual changes