Skip to main content
Technology & EngineeringTerraform472 lines

Terraform CI CD Pipeline

Running Terraform in CI/CD pipelines with automated plan, approval gates, and safe apply workflows

Quick Summary18 lines
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 lines
Paste into your CLAUDE.md or agent config

CI/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

  1. Lint and validateterraform 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

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 concurrency groups, GitLab resource_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 Atlantis apply_requirements to gate production applies.
  • Pin Terraform and provider versions. Use a .terraform-version file 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-color in 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 apply locally, 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 apply without -auto-approve in CI. Without this flag, Terraform prompts for confirmation and the pipeline hangs. However, always use saved plans when possible rather than -auto-approve with an unsaved plan.
  • Forgetting to run terraform init in every CI job. Each job starts fresh. The .terraform directory must be initialized (or restored from cache) before any Terraform command.

Install this skill directly: skilldb add terraform-skills

Get CLI access →