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:#fffWhat 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:#fffPipeline 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.