The Core Pattern#

The standard CI/CD pattern for Terraform: run terraform plan on every pull request and post the output as a PR comment. Run terraform apply only when the PR merges to main. This gives reviewers visibility into what will change before approving.

GitHub Actions Workflow#

name: Terraform
on:
  pull_request:
    paths: ["infra/**"]
  push:
    branches: [main]
    paths: ["infra/**"]

permissions:
  id-token: write    # OIDC
  contents: read
  pull-requests: write  # PR comments

jobs:
  terraform:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: infra
    steps:
      - uses: actions/checkout@v4

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/terraform-ci
          aws-region: us-east-1

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.7.0
          terraform_wrapper: true  # captures stdout for PR comments

      - name: Terraform Init
        run: terraform init -input=false

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

      - name: Terraform Validate
        run: terraform validate

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

      - name: Post Plan to PR
        if: github.event_name == 'pull_request'
        uses: actions/github-script@v7
        with:
          script: |
            const plan = `${{ steps.plan.outputs.stdout }}`;
            const truncated = plan.length > 60000
              ? plan.substring(0, 60000) + "\n\n... truncated ..."
              : plan;
            await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
              body: `#### Terraform Plan\n\`\`\`\n${truncated}\n\`\`\``
            });

      - name: Plan Status
        if: steps.plan.outcome == 'failure'
        run: exit 1

      - name: Terraform Apply
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        run: terraform apply -input=false tfplan

The plan step uses continue-on-error: true so the PR comment step still runs. A separate step checks the actual plan outcome. Apply only runs on pushes to main.

OIDC Authentication#

Never store AWS access keys as GitHub secrets. Use OIDC (OpenID Connect) to get short-lived credentials:

# In a separate bootstrap Terraform config
resource "aws_iam_openid_connect_provider" "github" {
  url             = "https://token.actions.githubusercontent.com"
  client_id_list  = ["sts.amazonaws.com"]
  thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}

resource "aws_iam_role" "terraform_ci" {
  name = "terraform-ci"
  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:myorg/infra:*"
        }
      }
    }]
  })
}

The sub condition restricts which repos and branches can assume the role. Tighten it to repo:myorg/infra:ref:refs/heads/main for the apply role, and use a more permissive plan-only role for PRs.

State Lock Management in CI#

CI pipelines can leave stale locks when jobs are cancelled or time out. Mitigations: set reasonable timeouts on your CI job, use -lock-timeout=5m on plan and apply so Terraform waits briefly for a lock to clear, and monitor for stuck locks. Never add force-unlock to your pipeline – that should be a manual operation after investigation.

Security: Plan Output Exposes Secrets#

terraform plan output can contain sensitive values – database passwords, API keys, certificates. If you post plan output to PR comments, anyone with repo read access sees those values. Mitigations: mark sensitive variables with sensitive = true (Terraform redacts them from plan output), avoid posting full plan output for configs that manage secrets, and use Terraform Cloud which handles plan output visibility separately from repo access.

Cost Estimation with Infracost#

Add a cost estimate step to your PR workflow:

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

      - name: Generate Cost Estimate
        run: |
          infracost breakdown --path=. \
            --format=json --out-file=/tmp/infracost.json
          infracost comment github \
            --path=/tmp/infracost.json \
            --repo=${{ github.repository }} \
            --pull-request=${{ github.event.pull_request.number }} \
            --github-token=${{ secrets.GITHUB_TOKEN }}

This posts a comment showing monthly cost impact: “This change will increase costs by $47/month.” Reviewers can catch accidentally oversized instances or forgotten resources before merge.

Policy as Code#

OPA (Open Policy Agent) evaluates plan JSON against Rego policies:

terraform plan -out=tfplan
terraform show -json tfplan > plan.json
opa eval --data policies/ --input plan.json "data.terraform.deny"
# policies/tags.rego
package terraform

deny[msg] {
  resource := input.resource_changes[_]
  resource.type == "aws_instance"
  not resource.change.after.tags.Owner
  msg := sprintf("Instance %s missing Owner tag", [resource.address])
}

Sentinel is HashiCorp’s commercial alternative, integrated into Terraform Cloud. It runs between plan and apply, blocking non-compliant changes.

Monorepo Patterns#

For repos with multiple Terraform root modules (e.g., infra/networking/, infra/compute/, infra/database/), detect which directories changed and run Terraform only for those:

      - name: Get Changed Directories
        id: dirs
        run: |
          dirs=$(git diff --name-only origin/main...HEAD \
            | grep '^infra/' \
            | cut -d'/' -f1-2 \
            | sort -u)
          echo "dirs=$dirs" >> "$GITHUB_OUTPUT"

Then loop over the directories or use a matrix strategy. Tools like Terragrunt, Spacelift, and Atlantis handle this natively, running plan/apply per directory with dependency ordering.

Platform Comparison#

Terraform Cloud: HashiCorp’s SaaS. Remote plan/apply, state management, Sentinel policies, cost estimation. Free for small teams.

Spacelift: More flexible policy engine, drift detection, better monorepo support, stack dependencies. Popular with larger teams managing hundreds of stacks.

Atlantis: Self-hosted, open source. Runs plan/apply via PR comments (atlantis plan, atlantis apply). Simple but you manage the server. Good for teams that want control without SaaS costs.

All three solve the same core problem: making Terraform safe in teams by enforcing the plan-review-apply workflow with proper locking and access control.