Terraform CI CD Pipeline
Running Terraform in CI/CD pipelines with automated plan, approval gates, and safe apply workflows
You are an expert in integrating Terraform into CI/CD pipelines for infrastructure as code. ## Key Points 1. **Lint and validate** — `terraform fmt -check`, `terraform validate`, TFLint 2. **Plan** — Generate and display the execution plan 3. **Security scan** — Checkov, tfsec, or Conftest policy checks 4. **Review** — Post plan output to the pull request for human review 5. **Approve** — Manual approval gate (for production) 6. **Apply** — Execute the approved plan 7. **Verify** — Run smoke tests or health checks - Plans should be **saved** (`-out=tfplan`) and the exact saved plan should be applied, not a new plan. - CI runners need **minimal IAM permissions** scoped to the infrastructure they manage. - **Concurrency must be controlled** — only one apply should run per state file at a time. - name: networking - name: application
skilldb get terraform-skills/Terraform CI CD PipelineFull skill: 472 linesCI/CD Pipeline — Terraform
You are an expert in integrating Terraform into CI/CD pipelines for infrastructure as code.
Overview
Running Terraform in CI/CD ensures that infrastructure changes go through the same review, testing, and approval process as application code. A typical pipeline runs terraform plan on pull requests for review, and terraform apply on merge to the main branch (optionally with a manual approval gate). The key challenges are state locking, secret management, concurrency control, and making plan output readable for reviewers.
Core Concepts
Pipeline Stages
- Lint and validate —
terraform fmt -check,terraform validate, TFLint - Plan — Generate and display the execution plan
- Security scan — Checkov, tfsec, or Conftest policy checks
- Review — Post plan output to the pull request for human review
- Approve — Manual approval gate (for production)
- Apply — Execute the approved plan
- Verify — Run smoke tests or health checks
Automation Principles
- Plans should be saved (
-out=tfplan) and the exact saved plan should be applied, not a new plan. - CI runners need minimal IAM permissions scoped to the infrastructure they manage.
- Concurrency must be controlled — only one apply should run per state file at a time.
Implementation Patterns
GitHub Actions
# .github/workflows/terraform.yml
name: Terraform
on:
pull_request:
branches: [main]
paths:
- 'infrastructure/**'
push:
branches: [main]
paths:
- 'infrastructure/**'
permissions:
contents: read
pull-requests: write
id-token: write # For OIDC authentication
env:
TF_VERSION: "1.7.0"
WORKING_DIR: "infrastructure"
jobs:
lint:
name: Lint and Validate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Terraform Format Check
run: terraform fmt -check -recursive
working-directory: ${{ env.WORKING_DIR }}
- name: Terraform Init
run: terraform init -backend=false
working-directory: ${{ env.WORKING_DIR }}
- name: Terraform Validate
run: terraform validate
working-directory: ${{ env.WORKING_DIR }}
- name: TFLint
uses: terraform-linters/setup-tflint@v4
with:
tflint_version: latest
- run: |
tflint --init
tflint --recursive
working-directory: ${{ env.WORKING_DIR }}
security:
name: Security Scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Checkov
uses: bridgecrewio/checkov-action@v12
with:
directory: ${{ env.WORKING_DIR }}
framework: terraform
quiet: true
plan:
name: Plan
runs-on: ubuntu-latest
needs: [lint, security]
if: github.event_name == 'pull_request'
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: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/terraform-ci
aws-region: us-east-1
- name: Terraform Init
run: terraform init
working-directory: ${{ env.WORKING_DIR }}
- name: Terraform Plan
id: plan
run: |
terraform plan -no-color -out=tfplan 2>&1 | tee plan_output.txt
terraform show -no-color tfplan >> plan_output.txt
working-directory: ${{ env.WORKING_DIR }}
continue-on-error: true
- name: Post Plan to PR
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const plan = fs.readFileSync('${{ env.WORKING_DIR }}/plan_output.txt', 'utf8');
const truncated = plan.length > 60000
? plan.substring(0, 60000) + '\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\`\`\`\n${truncated}\n\`\`\`\n\nPlan exit code: \`${{ steps.plan.outcome }}\``
});
- name: Fail on Plan Error
if: steps.plan.outcome == 'failure'
run: exit 1
apply:
name: Apply
runs-on: ubuntu-latest
needs: [lint, security]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment: production # Requires manual approval in GitHub
concurrency:
group: terraform-apply
cancel-in-progress: false
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: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/terraform-ci
aws-region: us-east-1
- name: Terraform Init
run: terraform init
working-directory: ${{ env.WORKING_DIR }}
- name: Terraform Apply
run: terraform apply -auto-approve
working-directory: ${{ env.WORKING_DIR }}
GitLab CI
# .gitlab-ci.yml
image:
name: hashicorp/terraform:1.7.0
entrypoint: [""]
variables:
TF_DIR: infrastructure
stages:
- validate
- plan
- apply
cache:
key: terraform
paths:
- ${TF_DIR}/.terraform/
validate:
stage: validate
script:
- cd ${TF_DIR}
- terraform init -backend=false
- terraform fmt -check -recursive
- terraform validate
plan:
stage: plan
script:
- cd ${TF_DIR}
- terraform init
- terraform plan -out=tfplan
artifacts:
paths:
- ${TF_DIR}/tfplan
expire_in: 1 day
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
apply:
stage: apply
script:
- cd ${TF_DIR}
- terraform init
- terraform apply tfplan
dependencies:
- plan
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: manual
resource_group: terraform-apply # Prevents concurrent applies
OIDC Authentication (Preferred Over Static Keys)
# IAM role for CI (created separately)
data "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
}
resource "aws_iam_role" "terraform_ci" {
name = "terraform-ci"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRoleWithWebIdentity"
Effect = "Allow"
Principal = {
Federated = data.aws_iam_openid_connect_provider.github.arn
}
Condition = {
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
}
StringLike = {
"token.actions.githubusercontent.com:sub" = "repo:myorg/myrepo:*"
}
}
}]
})
}
resource "aws_iam_role_policy_attachment" "terraform_ci" {
role = aws_iam_role.terraform_ci.name
policy_arn = aws_iam_policy.terraform_ci.arn
}
Multi-Environment Pipeline
# .github/workflows/terraform-multi-env.yml
name: Terraform Multi-Environment
on:
push:
branches: [main]
paths:
- 'infrastructure/**'
jobs:
plan-all:
strategy:
matrix:
environment: [dev, staging, prod]
fail-fast: false
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- name: Init and Plan
run: |
cd infrastructure
terraform init
terraform workspace select ${{ matrix.environment }}
terraform plan \
-var-file=environments/${{ matrix.environment }}.tfvars \
-out=${{ matrix.environment }}.tfplan
- uses: actions/upload-artifact@v4
with:
name: plan-${{ matrix.environment }}
path: infrastructure/${{ matrix.environment }}.tfplan
apply-dev:
needs: plan-all
runs-on: ubuntu-latest
environment: dev
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- uses: actions/download-artifact@v4
with:
name: plan-dev
path: infrastructure/
- run: |
cd infrastructure
terraform init
terraform workspace select dev
terraform apply dev.tfplan
apply-staging:
needs: apply-dev
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- uses: actions/download-artifact@v4
with:
name: plan-staging
path: infrastructure/
- run: |
cd infrastructure
terraform init
terraform workspace select staging
terraform apply staging.tfplan
apply-prod:
needs: apply-staging
runs-on: ubuntu-latest
environment: production # Manual approval required
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- uses: actions/download-artifact@v4
with:
name: plan-prod
path: infrastructure/
- run: |
cd infrastructure
terraform init
terraform workspace select prod
terraform apply prod.tfplan
Atlantis (Pull Request Automation)
# atlantis.yaml
version: 3
projects:
- name: networking
dir: infrastructure/networking
workspace: default
autoplan:
when_modified:
- "*.tf"
- "*.tfvars"
enabled: true
apply_requirements:
- approved
- mergeable
- name: application
dir: infrastructure/application
workspace: default
autoplan:
when_modified:
- "*.tf"
- "*.tfvars"
enabled: true
apply_requirements:
- approved
- mergeable
Makefile for Local and CI Consistency
# Makefile
ENVIRONMENT ?= dev
TF_DIR := infrastructure
.PHONY: init plan apply destroy fmt lint
init:
cd $(TF_DIR) && terraform init
plan: init
cd $(TF_DIR) && terraform workspace select $(ENVIRONMENT) && \
terraform plan -var-file=environments/$(ENVIRONMENT).tfvars -out=$(ENVIRONMENT).tfplan
apply: init
cd $(TF_DIR) && terraform workspace select $(ENVIRONMENT) && \
terraform apply $(ENVIRONMENT).tfplan
destroy: init
cd $(TF_DIR) && terraform workspace select $(ENVIRONMENT) && \
terraform destroy -var-file=environments/$(ENVIRONMENT).tfvars
fmt:
terraform fmt -recursive $(TF_DIR)
lint: fmt
cd $(TF_DIR) && terraform init -backend=false && terraform validate
tflint --recursive $(TF_DIR)
Best Practices
- Use OIDC for CI authentication instead of static access keys. OIDC provides short-lived credentials scoped to the pipeline run.
- Save and apply the exact plan. Generate a plan file with
-out, store it as an artifact, and apply that specific file. This prevents drift between plan review and apply. - Use concurrency controls. GitHub Actions
concurrencygroups, GitLabresource_group, or Atlantis locking prevent parallel applies to the same state. - Post plan output to pull requests. Make the plan visible to reviewers without requiring them to run Terraform locally.
- Require manual approval for production. Use GitHub Environments, GitLab
when: manual, or Atlantisapply_requirementsto gate production applies. - Pin Terraform and provider versions. Use a
.terraform-versionfile or specify the exact version in CI to prevent unexpected behavior from version differences. - Separate plan and apply jobs. The plan job runs on PR; the apply job runs on merge. This ensures every change is reviewed before it reaches infrastructure.
- Use
-no-colorin CI output when posting to PR comments, but keep colors in CI logs for readability.
Core Philosophy
Infrastructure changes deserve the same rigor as application code changes. A CI/CD pipeline for Terraform enforces this by making every change visible, reviewable, and auditable before it touches real infrastructure. The pipeline is not just automation for convenience; it is a safety mechanism that prevents the "I'll just apply this quickly from my laptop" mistakes that cause outages.
The golden rule of Terraform in CI is that the plan you reviewed is the plan you apply. Generating a plan on a pull request, approving it, and then running a fresh plan-and-apply on merge is dangerous because the infrastructure may have changed between those two moments. Save the plan artifact, and apply that exact artifact. This ensures reviewers and operators are always looking at the same truth.
Least privilege and short-lived credentials are non-negotiable in CI. A pipeline that holds long-lived admin keys is a single breach away from total infrastructure compromise. OIDC federation gives each pipeline run a scoped, time-limited token that cannot be reused or exfiltrated. Combined with separate read-only (plan) and write (apply) roles, this limits the damage of any single compromised step.
Anti-Patterns
-
Applying from developer laptops. When individuals run
terraform applylocally, there is no audit trail, no peer review, and no concurrency control. Changes bypass all safety gates and can conflict with other in-flight modifications. All applies should go through the pipeline. -
Auto-approving production deploys. Removing the manual approval gate for production to "move faster" trades a small amount of velocity for a large amount of risk. A single misconfigured variable can destroy a database, and the only thing standing between the mistake and the blast is a human reviewing the plan.
-
Sharing CI credentials across repositories. Using the same IAM role or service account for every repository's Terraform pipeline means a compromise in any one repository grants access to all infrastructure. Scope credentials per repository and per environment.
-
Ignoring plan output length in PR comments. Posting a 200,000-character plan to a pull request comment makes it unreadable and causes reviewers to skip it entirely. Truncate intelligently, highlight resource counts and destructive changes, and link to the full output in CI logs.
-
Running Terraform without version pinning in CI. If the CI runner picks up a newer Terraform version than the one used to write the configuration, subtle behavior differences can cause unexpected resource recreation or plan errors. Pin the exact Terraform version in CI and in
.terraform-version.
Common Pitfalls
- Plan drift between PR and apply. If time passes between the PR plan and the merge apply, infrastructure may have changed. Re-run plan immediately before apply or use saved plan artifacts.
- Storing AWS keys as repository secrets. Static keys are long-lived and broad. If leaked, they grant persistent access. Use OIDC federation.
- Not handling concurrent PRs. Two PRs modifying the same Terraform configuration can produce conflicting plans. State locking prevents corruption, but both plans may be stale after one applies.
- Overly broad CI IAM permissions. Grant the CI role only the permissions needed for the resources it manages. Use separate roles for plan-only (read) and apply (write) stages.
- Ignoring plan file staleness. A saved plan references specific state serial numbers. If another apply runs between plan and apply, the saved plan is rejected. This is a safety feature; do not work around it.
- Not cleaning up plan artifacts. Plan files can contain sensitive data (resource attributes, variable values). Set artifact expiration and do not persist them long-term.
- Running
terraform applywithout-auto-approvein CI. Without this flag, Terraform prompts for confirmation and the pipeline hangs. However, always use saved plans when possible rather than-auto-approvewith an unsaved plan. - Forgetting to run
terraform initin every CI job. Each job starts fresh. The.terraformdirectory must be initialized (or restored from cache) before any Terraform command.
Install this skill directly: skilldb add terraform-skills
Related Skills
Terraform Basics
Terraform fundamentals including providers, resources, data sources, and core workflow
Terraform Modules
Terraform module design, composition, versioning, and reuse patterns
Terraform Provisioners
Terraform provisioners, null resources, triggers, and when to use alternatives
Terraform State Management
Remote state backends, state locking, import, migration, and state surgery techniques
Terraform Testing
Testing Terraform configurations with native tests, Terratest, plan validation, and policy-as-code
Terraform Variables Outputs
Terraform variables, outputs, locals, type constraints, validation, and data flow patterns