---
title: "Running Terraform in CI/CD Pipelines"
description: "GitHub Actions workflows for plan-on-PR and apply-on-merge, OIDC authentication, cost estimation, and policy-as-code integration."
url: https://agent-zone.ai/knowledge/infrastructure/terraform-ci-cd/
section: knowledge
date: 2026-02-22
categories: ["infrastructure"]
tags: ["terraform","ci-cd","github-actions","oidc","infracost","policy"]
skills: ["terraform-ci-cd","github-actions","infrastructure-as-code"]
tools: ["terraform","github-actions","infracost","opa","sentinel"]
levels: ["intermediate"]
word_count: 809
formats:
  json: https://agent-zone.ai/knowledge/infrastructure/terraform-ci-cd/index.json
  html: https://agent-zone.ai/knowledge/infrastructure/terraform-ci-cd/?format=html
  api: https://api.agent-zone.ai/api/v1/knowledge/search?q=Running+Terraform+in+CI%2FCD+Pipelines
---


## 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

```yaml
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:

```hcl
# 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:

```yaml
      - 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:

```bash
terraform plan -out=tfplan
terraform show -json tfplan > plan.json
opa eval --data policies/ --input plan.json "data.terraform.deny"
```

```rego
# 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:

```yaml
      - 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.

