CI Pipeline #

Running terraform plan locally before pushing is a good habit, but it’s not enough. You might forget, another teammate might not know you already planned, and nobody can verify that the plan you ran is the same one that will be applied. A CI pipeline solves all of this — every change to the Terraform configuration is automatically validated and planned, and the result can be seen by everyone before the apply decision is made.

flowchart TD
    A["Push Code"] --> B["Checkout"]
    B --> C["Setup Terraform"]
    C --> D["Validate"]
    D --> E["Lint Security"]
    E --> F["Plan -out=tfplan"]
    F --> G["Post to PR"]
    G --> H["Review"]
    H --> I["Apply"]

    style A fill:#3b82f6,stroke:#1e40af,color:#fff
    style B fill:#3b82f6,stroke:#1e40af,color:#fff
    style C fill:#3b82f6,stroke:#1e40af,color:#fff
    style D fill:#10b981,stroke:#059669,color:#fff
    style E fill:#ef4444,stroke:#dc2626,color:#fff
    style F fill:#f59e0b,stroke:#d97706,color:#fff
    style G fill:#3b82f6,stroke:#1e40af,color:#fff
    style H fill:#f97316,stroke:#ea580c,color:#fff
    style I fill:#10b981,stroke:#059669,color:#fff

What a Terraform CI Pipeline Should Do #

A CI pipeline for Terraform isn’t just “run terraform plan and see if it errors”. There are more complete stages that make the pipeline truly useful.

STAGES OF A GOOD TERRAFORM CI PIPELINE:

  1. CHECKOUT
     Clone the repository, make sure all files are available

  2. SETUP
     Install the correct Terraform version (from .terraform-version or a pinned version)

  3. VALIDATE (fast, no cloud access)
     terraform fmt -check     ← Check consistent formatting
     terraform validate        ← Check syntax and references

  4. LINT (optional but recommended)
     tflint                    ← Detect best practice violations
     checkov or trivy          ← Security scan

  5. PLAN (needs cloud access)
     terraform init
     terraform plan -out=tfplan
     Save tfplan as an artifact

  6. SHOW THE PLAN (for review)
     terraform show -no-color tfplan > plan.txt
     Post the plan to the PR as a comment

  AFTER REVIEW & APPROVAL:
  7. APPLY (in a separate pipeline, triggered manually or on auto-merge)
     terraform apply tfplan

GitHub Actions: Complete Pipeline #

# .github/workflows/terraform.yml

name: Terraform CI

on:
  pull_request:
    branches: [main]
    paths:
      - 'infrastructure/**'  # Only trigger if there are changes in this directory
  push:
    branches: [main]
    paths:
      - 'infrastructure/**'

env:
  TF_VERSION: "1.7.0"
  WORKING_DIR: "infrastructure/environments/production"

jobs:
  validate:
    name: Validate
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Terraform
        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 (for validate)
        run: terraform init -backend=false
        working-directory: ${{ env.WORKING_DIR }}

      - name: Terraform Validate
        run: terraform validate
        working-directory: ${{ env.WORKING_DIR }}

  plan:
    name: Plan
    runs-on: ubuntu-latest
    needs: validate  # Run after validate succeeds
    if: github.event_name == 'pull_request'
    permissions:
      contents: read
      pull-requests: write  # Permission to post comments to PRs

    steps:
      - name: Checkout
        uses: actions/checkout@v4

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

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ vars.AWS_ACCOUNT_ID }}:role/TerraformCIRole
          aws-region: ap-southeast-1

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

      - name: Terraform Plan
        id: plan
        run: |
          terraform plan \
            -var-file="terraform.tfvars" \
            -out=tfplan \
            -no-color \
            2>&1 | tee plan_output.txt          
        working-directory: ${{ env.WORKING_DIR }}
        continue-on-error: true  # Continue even if plan errors, so a comment can be posted

      - name: Upload Plan Artifact
        uses: actions/upload-artifact@v4
        with:
          name: tfplan-${{ github.sha }}
          path: |
            ${{ env.WORKING_DIR }}/tfplan
            ${{ env.WORKING_DIR }}/plan_output.txt            
          retention-days: 5  # Keep for 5 days — enough for review and apply

      - 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 maxLength = 65000;  // GitHub PR comment limit
            const truncated = plan.length > maxLength
              ? plan.substring(0, maxLength) + '\n\n... (output 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: Check Plan Status
        if: steps.plan.outcome == 'failure'
        run: exit 1  # Fail the pipeline if the plan failed

GitLab CI: Complete Pipeline #

# .gitlab-ci.yml

variables:
  TF_VERSION: "1.7.0"
  WORKING_DIR: "infrastructure/environments/production"

stages:
  - validate
  - plan
  - apply

.terraform_base:
  image: hashicorp/terraform:$TF_VERSION
  before_script:
    - cd $WORKING_DIR
    - terraform init

validate:
  stage: validate
  image: hashicorp/terraform:$TF_VERSION
  before_script:
    - cd $WORKING_DIR
    - terraform init -backend=false
  script:
    - terraform fmt -check -recursive
    - terraform validate
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

plan:
  extends: .terraform_base
  stage: plan
  script:
    - terraform plan -out=tfplan -no-color | tee plan_output.txt
    - terraform show -no-color tfplan > plan_readable.txt
  artifacts:
    paths:
      - $WORKING_DIR/tfplan
      - $WORKING_DIR/plan_output.txt
      - $WORKING_DIR/plan_readable.txt
    expire_in: 5 days
    reports:
      # Show the plan in the GitLab MR sidebar (if supported)
      terraform: $WORKING_DIR/plan_readable.txt
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"

apply:
  extends: .terraform_base
  stage: apply
  script:
    - terraform apply tfplan
  dependencies:
    - plan  # Grab the tfplan artifact from the plan job
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: manual  # Requires a manual click in the GitLab UI
  environment:
    name: production

Managing Credentials in CI #

CI pipelines need credentials to run terraform plan and apply. The right way is using OIDC (OpenID Connect) — the pipeline gets temporary credentials without needing to store permanent secrets.

# GitHub Actions + AWS OIDC (the right way)
# No AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY needed in secrets

- name: Configure AWS Credentials via OIDC
  uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: arn:aws:iam::123456789:role/GitHubActionsRole
    aws-region: ap-southeast-1
    # GitHub Actions automatically provides an OIDC token
    # AWS verifies this token and issues temporary credentials

# IAM Role Trust Policy allowing GitHub Actions:
# {
#   "Effect": "Allow",
#   "Principal": {
#     "Federated": "arn:aws:iam::123456789:oidc-provider/token.actions.githubusercontent.com"
#   },
#   "Action": "sts:AssumeRoleWithWebIdentity",
#   "Condition": {
#     "StringEquals": {
#       "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
#       "token.actions.githubusercontent.com:sub":
#         "repo:myorg/myrepo:ref:refs/heads/main"
#     }
#   }
# }

flowchart TD
    A["feature branch"] -->|"PR"| B["CI: validate + plan"]
    B -->|"merge"| C["main branch"]
    C -->|"CI: plan + apply"| D["staging"]
    D -->|"manual approve"| E["production"]

    style A fill:#3b82f6,stroke:#1e40af,color:#fff
    style B fill:#10b981,stroke:#059669,color:#fff
    style C fill:#3b82f6,stroke:#1e40af,color:#fff
    style D fill:#f59e0b,stroke:#d97706,color:#fff
    style E fill:#ef4444,stroke:#dc2626,color:#fff

Pipeline Monitoring #

# Pipeline monitoring and alerting
# If the pipeline fails, send a notification

# Slack notification step
- name: Notify Slack on Failure
  if: failure()
  uses: 8398a7/action-slack@v3
  with:
    status: failure
    fields: repo,message,commit,author,action,eventName,ref,workflow
  env:
    SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}

# Teams notification
- name: Notify Teams
  if: always()
  uses: skitionek/notify-microsoft-teams@master
  with:
    webhook_url: ${{ secrets.TEAMS_WEBHOOK }}
    overwrite: "{title: 'Terraform ${{ job.status }}', summary: 'Pipeline result'}"
# Pipeline metrics collection
# Track: plan duration, apply duration, resource count changes
terraform plan -json > plan.json
jq '{resource_changes: [.resource_changes[] | {address, change: .change.actions}]}' plan.json

# Count changes
terraform plan -json | jq '[.resource_changes[]] | length'

Pipeline Optimization #

# Speed up pipelines:
# 1. Cache provider plugins
# 2. Run plans/applies per stack in parallel
# 3. Skip unchanged stacks
# 4. Use -refresh=false when safe

# GitHub Actions caching
- uses: actions/cache@v3
  with:
    path: |
      ~/.terraform.d/plugin-cache
      .terraform
    key: tf-${{ hashFiles('.terraform.lock.hcl') }}

# Parallel execution
terraform plan -target=module.networking &
terraform plan -target=module.compute &
wait

Pipeline Security #

# Security scanning in the pipeline
- name: Run tfsec
  uses: aquasecurity/[email protected]
  with:
    working_directory: .
    soft_fail: false

- name: Run checkov
  uses: bridgecrewio/checkov-action@v12
  with:
    directory: .
    framework: terraform
    quiet: true
    soft_fail: false
# Secret scanning in the pipeline
# Make sure there are no secrets in tfvars
grep -rn 'password\|secret\|api_key' --include='*.tfvars' .
if [ $? -eq 0 ]; then
  echo 'SECRETS FOUND IN TFVARS!'
  exit 1
fi

Summary #

  • A Terraform CI pipeline consists of several stages: format check, validate, plan, showing the plan to the PR, then a separate apply after approval.
  • Save the plan artifact (-out=tfplan) so apply uses exactly the plan that was reviewed — not a new plan that may differ.
  • Post the plan to the PR as a comment — everyone on the team can see what will change before merging.
  • Use OIDC for credentials in CI/CD — no need to store AWS access keys as permanent secrets, safer and easier to rotate.
  • Separate validate and plan — validate without cloud access (faster), plan with cloud access (but slower).
  • Apply is triggered separately after approval, not automatically after plan — this ensures human review before infrastructure changes.

← Previous: State Sharing   Next: Plan Approval Strategy →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact