Advanced Terraform

CI/CD for Terraform

● Advanced ⏱ 25 min read terraform

Running terraform apply from a developer's laptop works for one person. It breaks down the moment you have two engineers, two environments, and infrastructure that's depended on by production services. CI/CD for Terraform brings the same reliability guarantees to infrastructure that application teams expect from their code pipelines: reviewed, tested, automated, and auditable.

Why Automate Terraform?

Manual Terraform operations create a class of problems that automation solves:

Manual problemCI/CD solution
"It worked on my machine" — different provider versionsPinned versions in CI runner image
Unapplied plans — someone forgot to apply after a planApply triggered automatically on merge
Credentials on developer laptopsShort-lived OIDC tokens in CI
No audit trail of who changed whatGit history + CI logs
Concurrent applies causing state corruptionState locking + serialized apply

Plan on PR, Apply on Merge

The canonical Terraform CI/CD pattern is two-phase:

  1. On pull request — run terraform plan and post the output as a PR comment. Engineers review the plan before approving.
  2. On merge to main — run terraform apply automatically, using the saved plan from step 1.

Saving the plan as an artifact bridges the two phases — the apply executes exactly what was reviewed:

# Save plan as artifact in the plan job
terraform plan -out=tfplan

# In the apply job, download and apply
terraform apply tfplan
⚠️
Plan files can become stale

If another merge lands between your plan and your apply, the apply will fail with a state conflict. The safe pattern is to re-plan at apply time, or require fast merges and serialize the apply queue.

GitHub Actions Workflow

A complete two-job workflow for a Terraform root module:

name: Terraform

on:
  pull_request:
    branches: [main]
    paths: ['infra/**', '!infra/**.md']
  push:
    branches: [main]
    paths: ['infra/**', '!infra/**.md']

env:
  TF_VERSION: "~1.9"
  WORKING_DIR: infra/

jobs:
  plan:
    name: Plan
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request'
    permissions:
      contents: read
      pull-requests: write
      id-token: write          # for OIDC

    steps:
      - uses: actions/checkout@v4

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ env.TF_VERSION }}

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

      - name: Terraform Init
        working-directory: ${{ env.WORKING_DIR }}
        run: terraform init

      - name: Terraform Plan
        id: plan
        working-directory: ${{ env.WORKING_DIR }}
        run: |
          terraform plan -out=tfplan -no-color 2>&1 | tee plan.txt
          echo "exitcode=${PIPESTATUS[0]}" >> $GITHUB_OUTPUT

      - name: Post Plan to PR
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const plan = fs.readFileSync('${{ env.WORKING_DIR }}plan.txt', 'utf8');
            const truncated = plan.length > 60000
              ? plan.substring(0, 60000) + '\n\n... (truncated)'
              : plan;
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: '## Terraform Plan\n```\n' + truncated + '\n```'
            });

      - name: Upload Plan
        uses: actions/upload-artifact@v4
        with:
          name: tfplan
          path: ${{ env.WORKING_DIR }}tfplan

  apply:
    name: Apply
    runs-on: ubuntu-latest
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    environment: production    # requires manual approval in GitHub
    permissions:
      contents: read
      id-token: write

    steps:
      - uses: actions/checkout@v4

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ env.TF_VERSION }}

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

      - name: Terraform Init
        working-directory: ${{ env.WORKING_DIR }}
        run: terraform init

      - name: Terraform Apply
        working-directory: ${{ env.WORKING_DIR }}
        run: terraform apply -auto-approve

Secrets in CI

Never put long-lived AWS access keys in CI secrets if you can avoid it. OIDC (OpenID Connect) lets GitHub Actions assume an IAM role directly using a short-lived token — no static credentials to rotate or leak.

Set up the OIDC trust on the AWS side:

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" "github_actions_plan" {
  name = "github-actions-terraform-plan"

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

Use separate roles for plan and apply — the plan role gets read-only access; the apply role gets write access but is restricted to protected environments:

RolePermissionsWhen assumed
Plan roleRead-only (describe, list, get)Every PR
Apply roleRead + write (create, update, delete)Merge to main only

Atlantis

Atlantis is a self-hosted Terraform PR automation tool. Instead of writing GitHub Actions YAML, you point Atlantis at your repo and it handles plan/apply via PR comments:

# In a PR comment:
atlantis plan    # triggers terraform plan, posts output
atlantis apply   # applies the last plan (requires approval in config)

Configure with atlantis.yaml at the repo root:

version: 3
projects:
  - name: networking
    dir: infra/networking
    workspace: default
    autoplan:
      when_modified: ["*.tf", "*.tfvars"]
      enabled: true

  - name: compute
    dir: infra/compute
    workspace: default
    depends_on: [networking]
    autoplan:
      when_modified: ["*.tf"]
      enabled: true

Atlantis advantages over raw GitHub Actions:

HCP Terraform Runs

HCP Terraform (formerly Terraform Cloud) is HashiCorp's managed CI/CD for Terraform. When you push to a connected VCS branch, HCP Terraform runs plan automatically in its managed environment:

terraform {
  cloud {
    organization = "my-org"
    workspaces {
      name = "production-networking"
    }
  }
}

Key HCP Terraform features:

🧭
Free tier is generous

HCP Terraform's free tier covers up to 500 managed resources across unlimited workspaces for a single user. For small teams or hobby projects, it replaces the need to build and maintain your own CI/CD pipeline for Terraform.

Policy Gates

Policy gates enforce rules on Terraform plans before they can be applied. Two main approaches:

OPA (Open Policy Agent)

Convert the plan to JSON and evaluate it against Rego policies:

# In CI, after terraform plan -out=tfplan
terraform show -json tfplan > plan.json

# Evaluate with OPA
opa eval --data policies/ --input plan.json \
  "data.terraform.deny" --format raw
# policies/require_tags.rego
package terraform

import future.keywords

deny contains msg if {
  resource := input.resource_changes[_]
  resource.change.actions[_] in {"create", "update"}
  resource.type == "aws_instance"
  not resource.change.after.tags.Environment
  msg := sprintf("aws_instance %v missing required tag: Environment", [resource.address])
}

Checkov

Static analysis for Terraform — no plan required, runs against .tf files directly:

- name: Checkov Security Scan
  uses: bridgecrewio/checkov-action@master
  with:
    directory: infra/
    framework: terraform
    soft_fail: false
    skip_check: CKV_AWS_8,CKV_AWS_79   # suppress known acceptable risks

Checkov catches common misconfigurations: unencrypted S3 buckets, public security group rules, missing logging, overly permissive IAM.

Key Takeaways