Infrastructure changes need the same rigor as application changes. Terraform in CI/CD brings pull request reviews, automated planning, and safe applies to your infrastructure. In this lesson, you'll learn to run terraform plan on every PR, automatically apply on merge, scan for security issues, and enforce policies—all in your pipeline.
1. Learning Objectives
By the end of this lesson, you will be able to:
- Run terraform plan in CI/CD pipelines
- Automatically apply infrastructure changes on merge
- Manage Terraform state in pipelines
- Scan Terraform code for security issues
- Enforce policies with Sentinel or OPA
- Implement drift detection and remediation
2. Why This Matters
Real-world scenario: A developer changes a production security group rule. Without CI/CD, they apply directly—possibly breaking access. With CI/CD, the PR runs terraform plan, a reviewer sees what will change, and automated tests validate the change. Only after approval does the pipeline apply.
3. Core Concepts
Terraform Plan in Pull Requests
# .github/workflows/terraform-pr.yml
name: Terraform PR Check
on:
pull_request:
paths:
- 'terraform/**'
- '*.tf'
env:
TF_VERSION: 1.6.0
WORKING_DIR: ./terraform
jobs:
terraform-plan:
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v2
with:
terraform_version: ${{ env.TF_VERSION }}
terraform_wrapper: false
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Terraform Init
run: terraform init
working-directory: ${{ env.WORKING_DIR }}
- name: Terraform Validate
run: terraform validate
working-directory: ${{ env.WORKING_DIR }}
- name: Terraform Plan
id: plan
run: terraform plan -no-color -out=tfplan
working-directory: ${{ env.WORKING_DIR }}
continue-on-error: true
- name: Update Pull Request
uses: actions/github-script@v7
env:
PLAN: "terraform\n${{ steps.plan.outputs.stdout }}"
with:
script: |
const output = `#### Terraform Plan 📖\n\n<details><summary>Show Plan</summary>\n\n\`\`\`\n${process.env.PLAN}\n\`\`\`\n</details>`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: output
});
- name: Terraform Plan Status
if: steps.plan.outcome == 'failure'
run: exit 1
Terraform plan on pull requests
Terraform Apply on Merge
# .github/workflows/terraform-apply.yml
name: Terraform Apply
on:
push:
branches: [main]
paths:
- 'terraform/**'
- '*.tf'
env:
TF_VERSION: 1.6.0
WORKING_DIR: ./terraform
jobs:
terraform-apply:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v2
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
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
working-directory: ${{ env.WORKING_DIR }}
- name: Terraform Apply
run: terraform apply -auto-approve tfplan
working-directory: ${{ env.WORKING_DIR }}
- name: Apply Summary
run: |
echo "✅ Terraform apply completed"
echo "Commit: ${{ github.sha }}"
echo "Author: ${{ github.actor }}"
working-directory: ${{ env.WORKING_DIR }}
Terraform apply on merge to main
Multi-Environment Deployments
# .github/workflows/terraform-environments.yml
name: Multi-Environment Terraform
on:
push:
branches:
- main
- develop
paths:
- 'terraform/**'
env:
TF_VERSION: 1.6.0
jobs:
terraform-plan-dev:
if: github.ref == 'refs/heads/develop'
runs-on: ubuntu-latest
environment: dev
steps:
- uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v2
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Terraform Init
run: terraform init -backend-config=environments/dev/backend.tfvars
working-directory: ./terraform
- name: Terraform Apply
run: terraform apply -auto-approve -var-file=environments/dev/terraform.tfvars
working-directory: ./terraform
terraform-plan-staging:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v2
- name: Terraform Init
run: terraform init -backend-config=environments/staging/backend.tfvars
working-directory: ./terraform
- name: Terraform Plan
id: plan
run: terraform plan -no-color -var-file=environments/staging/terraform.tfvars
working-directory: ./terraform
terraform-apply-production:
needs: terraform-plan-staging
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment:
name: production
url: https://myapp.example.com
steps:
- uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v2
- name: Terraform Init
run: terraform init -backend-config=environments/production/backend.tfvars
working-directory: ./terraform
- name: Terraform Apply
run: terraform apply -auto-approve -var-file=environments/production/terraform.tfvars
working-directory: ./terraform
Multi-environment Terraform deployments
Security Scanning (tfsec, checkov)
# .github/workflows/terraform-security.yml
name: Terraform Security Scan
on:
pull_request:
paths:
- 'terraform/**'
- '*.tf'
push:
branches: [main]
jobs:
security-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# tfsec - static analysis for Terraform
- name: Run tfsec
uses: aquasecurity/tfsec-sarif-action@v0.1.4
with:
sarif_file: tfsec.sarif
working_directory: ./terraform
# Checkov - comprehensive scanning
- name: Run Checkov
uses: bridgecrewio/checkov-action@master
with:
directory: ./terraform
framework: terraform
output_format: cli
soft_fail: false
quiet: false
# tflint - linting
- name: Setup tflint
run: curl -s https://raw.githubusercontent.com/terraform-linters/tflint/master/install_linux.sh | bash
- name: Run tflint
run: tflint --chdir=./terraform
# Infracost - cost estimation
- name: Setup Infracost
uses: infracost/actions/setup@v2
with:
api-key: ${{ secrets.INFRACOST_API_KEY }}
- name: Run Infracost
run: |
infracost breakdown --path ./terraform \
--format diff \
--out-file infracost.txt
- name: Upload security results
uses: github/codeql-action/upload-sarif@v2
with:
sarif_file: tfsec.sarif
Terraform security scanning in CI
Drift Detection
# .github/workflows/terraform-drift.yml
name: Terraform Drift Detection
on:
schedule:
- cron: '0 */6 * * *' # Every 6 hours
workflow_dispatch: # Manual trigger
jobs:
detect-drift:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v2
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Terraform Init
run: terraform init
working-directory: ./terraform
- name: Detect Drift
id: plan
run: terraform plan -detailed-exitcode -no-color
working-directory: ./terraform
continue-on-error: true
- name: Drift Detected
if: steps.plan.outcome == 'failure' && steps.plan.exitcode == 2
run: |
echo "⚠️ Drift detected in infrastructure!"
echo "Changes found that aren't in code."
# Send alert
curl -X POST -H 'Content-type: application/json' \
--data '{"text":"🚨 Infrastructure drift detected in production!"}' \
${{ secrets.SLACK_WEBHOOK }}
- name: Auto-remediate drift (optional)
if: github.event_name == 'workflow_dispatch'
run: terraform apply -auto-approve
working-directory: ./terraform
Infrastructure drift detection
4. Complete Project: Production Terraform Pipeline
# .github/workflows/terraform-production.yml
name: Production Terraform Pipeline
on:
pull_request:
paths:
- 'terraform/**'
push:
branches: [main]
paths:
- 'terraform/**'
workflow_dispatch:
inputs:
action:
description: 'Terraform action'
required: true
default: 'plan'
type: choice
options:
- plan
- apply
- destroy
env:
TF_VERSION: 1.6.0
WORKING_DIR: ./terraform/production
concurrency:
group: terraform-production
cancel-in-progress: false
jobs:
terraform-checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v2
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Terraform Format
run: terraform fmt -check -recursive
working-directory: ./terraform
- name: Terraform Validate
run: terraform validate
working-directory: ${{ env.WORKING_DIR }}
- name: Run tfsec
uses: aquasecurity/tfsec-sarif-action@v0.1.4
with:
sarif_file: tfsec.sarif
working_directory: ${{ env.WORKING_DIR }}
- name: Run Checkov
uses: bridgecrewio/checkov-action@master
with:
directory: ${{ env.WORKING_DIR }}
framework: terraform
soft_fail: true
terraform-plan:
needs: terraform-checks
if: github.event_name == 'pull_request' || (github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'plan')
runs-on: ubuntu-latest
environment: production-plan
steps:
- uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v2
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
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
working-directory: ${{ env.WORKING_DIR }}
- name: Comment Plan on PR
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '## Terraform Plan\n\n```\n${{ steps.plan.outputs.stdout }}\n```'
});
terraform-apply:
needs: terraform-checks
if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'apply')
runs-on: ubuntu-latest
environment:
name: production
url: https://myapp.example.com
steps:
- uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v2
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Terraform Init
run: terraform init
working-directory: ${{ env.WORKING_DIR }}
- name: Terraform Apply
id: apply
run: terraform apply -auto-approve
working-directory: ${{ env.WORKING_DIR }}
- name: Output Summary
run: |
echo "✅ Terraform apply completed successfully"
echo "Infrastructure changes deployed: ${{ github.sha }}"
working-directory: ${{ env.WORKING_DIR }}
- name: Notify Slack
uses: slackapi/slack-github-action@v1
with:
payload: |
{
"text": "✅ Infrastructure updated: ${{ github.sha }}",
"blocks": [{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Terraform Apply Successful*\nEnvironment: production\nCommit: ${{ github.sha }}\nAuthor: ${{ github.actor }}"
}
}]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
terraform-destroy:
needs: terraform-checks
if: github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'destroy'
runs-on: ubuntu-latest
environment: production-destroy
steps:
- uses: actions/checkout@v4
- name: Confirm destroy
run: |
echo "⚠️ DESTROY CONFIRMATION REQUIRED"
echo "This will delete all infrastructure in production!"
read -p "Type 'DESTROY' to confirm: " confirm
if [ "$confirm" != "DESTROY" ]; then
echo "Aborted."
exit 1
fi
- name: Setup Terraform
uses: hashicorp/setup-terraform@v2
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
- name: Terraform Init
run: terraform init
working-directory: ${{ env.WORKING_DIR }}
- name: Terraform Destroy
run: terraform destroy -auto-approve
working-directory: ${{ env.WORKING_DIR }}
Complete production Terraform pipeline
5. Common Errors & Solutions
6. Summary Checklist
7. Next Steps
Next lesson: CI/CD Lesson 7.5: Advanced CI/CD Patterns
Comments (0)
This is exactly what I needed! The initContainer approach solved our migration issues completely. Thanks for the detailed guide!
ReplyLeave a Comment