CI/CD for 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 problem | CI/CD solution |
|---|---|
| "It worked on my machine" — different provider versions | Pinned versions in CI runner image |
| Unapplied plans — someone forgot to apply after a plan | Apply triggered automatically on merge |
| Credentials on developer laptops | Short-lived OIDC tokens in CI |
| No audit trail of who changed what | Git history + CI logs |
| Concurrent applies causing state corruption | State locking + serialized apply |
Plan on PR, Apply on Merge
The canonical Terraform CI/CD pattern is two-phase:
- On pull request — run
terraform planand post the output as a PR comment. Engineers review the plan before approving. - On merge to main — run
terraform applyautomatically, 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
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:
| Role | Permissions | When assumed |
|---|---|---|
| Plan role | Read-only (describe, list, get) | Every PR |
| Apply role | Read + 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:
- Automatic plan on every PR (no YAML to write per module)
- Serialized applies — prevents concurrent state modifications
- Locks a PR's workspace until the apply runs
- Built-in support for multi-module monorepos
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:
- Remote state — managed backend, no S3 bucket to configure
- Variable sets — share credentials across workspaces without duplicating secrets
- Run triggers — workspace A's apply triggers workspace B's plan automatically
- Sentinel — policy-as-code framework for enforcing rules before apply (paid tiers)
- Drift detection — scheduled plans to detect drift between runs
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
- The core pattern: plan on PR (show reviewers what will change), apply on merge (automated, from the reviewed plan).
- Use OIDC instead of static credentials in CI. Plan role gets read-only; apply role gets write access, restricted to protected branches.
- Save
terraform plan -out=tfplanas a CI artifact and apply it withterraform apply tfplanto guarantee apply matches what was reviewed. - Atlantis is the pragmatic choice for teams who want PR-driven Terraform without writing extensive CI YAML. HCP Terraform is the managed option with built-in state, runs, and policy.
- Policy gates (OPA, Checkov) enforce security and compliance rules automatically — don't rely on code review alone to catch infrastructure misconfigurations.