Infrastructure as code needs CI/CD just like application code. Automated terraform plan on every PR. Automatic apply on merge. Security scanning before deployment. In this lesson, you'll build a production-grade Terraform pipeline that your whole team can rely on.
1. Learning Objectives
By the end of this lesson, you will be able to:
- Build complete Terraform CI/CD pipelines
- Implement plan on PR and apply on merge
- Manage Terraform state in pipelines
- Add security scanning to Terraform code
- Implement approval gates for production
- Use Terragrunt for multi-environment management
2. Why This Matters
Real-world scenario: Your team manages infrastructure across dev, staging, and production. Manual terraform apply leads to mistakes, untracked changes, and no audit trail. CI/CD brings the same rigor to infrastructure as application code.
3. Core Concepts
GitHub Actions Terraform Pipeline
# .github/workflows/terraform.yml
name: Terraform CI/CD
on:
pull_request:
paths:
- 'terraform/**'
push:
branches: [main]
paths:
- 'terraform/**'
workflow_dispatch:
inputs:
environment:
description: 'Environment to deploy'
required: true
default: 'dev'
type: choice
options:
- dev
- staging
- production
env:
TF_VERSION: 1.6.0
WORKING_DIR: ./terraform
jobs:
terraform-fmt:
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: ${{ env.WORKING_DIR }}
terraform-validate:
runs-on: ubuntu-latest
needs: terraform-fmt
steps:
- uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v2
- name: Terraform Init
run: terraform init -backend=false
working-directory: ${{ env.WORKING_DIR }}
- name: Terraform Validate
run: terraform validate
working-directory: ${{ env.WORKING_DIR }}
terraform-plan:
runs-on: ubuntu-latest
needs: terraform-validate
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v2
- name: Configure AWS
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 }}
continue-on-error: true
- name: Comment Plan on PR
uses: actions/github-script@v7
env:
PLAN: ${{ steps.plan.outputs.stdout }}
with:
script: |
const body = `## Terraform Plan
<details><summary>Show Plan</summary>
\`\`\`
${process.env.PLAN}
\`\`\`
</details>`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: body
});
- name: Plan Status
if: steps.plan.outcome == 'failure'
run: exit 1
terraform-apply:
runs-on: ubuntu-latest
needs: terraform-plan
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
environment: production
steps:
- uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v2
- name: Configure AWS
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
run: terraform apply -auto-approve
working-directory: ${{ env.WORKING_DIR }}
- name: Notify Slack
uses: slackapi/slack-github-action@v1
with:
payload: |
{
"text": "✅ Terraform apply completed: ${{ github.sha }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
Complete Terraform CI/CD pipeline
Multi-Environment Pipeline
# .github/workflows/terraform-environments.yml
name: Multi-Environment Terraform
on:
push:
branches: [main]
paths:
- 'terraform/**'
workflow_dispatch:
inputs:
environment:
description: 'Environment'
required: true
type: environment
jobs:
terraform-dev:
runs-on: ubuntu-latest
environment: dev
env:
TF_WORKSPACE: dev
steps:
- uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v2
- name: Configure AWS
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 -backend-config=environments/${{ env.TF_WORKSPACE }}/backend.tfvars
working-directory: ./terraform
- name: Terraform Workspace
run: terraform workspace select ${{ env.TF_WORKSPACE }} || terraform workspace new ${{ env.TF_WORKSPACE }}
working-directory: ./terraform
- name: Terraform Apply
run: terraform apply -auto-approve -var-file=environments/${{ env.TF_WORKSPACE }}/terraform.tfvars
working-directory: ./terraform
terraform-staging:
runs-on: ubuntu-latest
environment: staging
needs: terraform-dev
env:
TF_WORKSPACE: staging
steps:
- uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v2
- name: Configure AWS
uses: aws-actions/configure-aws-credentials@v4
- name: Terraform Init
run: terraform init -backend-config=environments/${{ env.TF_WORKSPACE }}/backend.tfvars
working-directory: ./terraform
- name: Terraform Workspace
run: terraform workspace select ${{ env.TF_WORKSPACE }} || terraform workspace new ${{ env.TF_WORKSPACE }}
working-directory: ./terraform
- name: Terraform Plan
id: plan
run: terraform plan -no-color -var-file=environments/${{ env.TF_WORKSPACE }}/terraform.tfvars
working-directory: ./terraform
- name: Upload Plan
uses: actions/upload-artifact@v4
with:
name: staging-plan
path: terraform/tfplan
terraform-production:
runs-on: ubuntu-latest
environment:
name: production
url: https://myapp.example.com
needs: terraform-staging
env:
TF_WORKSPACE: production
steps:
- uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v2
- name: Configure AWS
uses: aws-actions/configure-aws-credentials@v4
- name: Terraform Init
run: terraform init -backend-config=environments/${{ env.TF_WORKSPACE }}/backend.tfvars
working-directory: ./terraform
- name: Terraform Workspace
run: terraform workspace select ${{ env.TF_WORKSPACE }} || terraform workspace new ${{ env.TF_WORKSPACE }}
working-directory: ./terraform
- name: Terraform Apply
run: terraform apply -auto-approve -var-file=environments/${{ env.TF_WORKSPACE }}/terraform.tfvars
working-directory: ./terraform
Multi-environment Terraform pipeline
Terragrunt Integration
# terragrunt.hcl - Root configuration
terraform {
source = "git::git@github.com:mycompany/terraform-modules.git//vpc?ref=v1.0.0"
}
inputs = {
environment = "${get_env("ENVIRONMENT", "dev")}"
}
# environments/dev/terragrunt.hcl
include "root" {
path = find_in_parent_folders()
}
inputs = {
vpc_cidr = "10.0.0.0/16"
environment = "dev"
instance_type = "t3.micro"
}
# environments/prod/terragrunt.hcl
include "root" {
path = find_in_parent_folders()
}
inputs = {
vpc_cidr = "10.0.0.0/16"
environment = "production"
instance_type = "t3.medium"
}
Terragrunt for DRY infrastructure
# .github/workflows/terragrunt.yml
name: Terragrunt CI/CD
on:
pull_request:
paths:
- 'environments/**'
push:
branches: [main]
paths:
- 'environments/**'
jobs:
terragrunt-plan:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- name: Install Terragrunt
run: |
curl -Lo terragrunt https://github.com/gruntwork-io/terragrunt/releases/download/v0.54.0/terragrunt_linux_amd64
chmod +x terragrunt
sudo mv terragrunt /usr/local/bin/
- name: Run Terragrunt Plan
run: |
for env in dev staging; do
cd environments/$env
terragrunt plan -out=tfplan
cd ../..
done
terragrunt-apply:
runs-on: ubuntu-latest
if: github.event_name == 'push'
environment: production
steps:
- uses: actions/checkout@v4
- name: Install Terragrunt
run: |
curl -Lo terragrunt https://github.com/gruntwork-io/terragrunt/releases/download/v0.54.0/terragrunt_linux_amd64
chmod +x terragrunt
sudo mv terragrunt /usr/local/bin/
- name: Run Terragrunt Apply
run: |
for env in dev staging production; do
cd environments/$env
terragrunt apply -auto-approve
cd ../..
done
Terragrunt CI/CD pipeline
4. Complete Project: Production Pipeline
# .github/workflows/terraform-production.yml
name: Production Terraform Pipeline
on:
pull_request:
branches: [main]
paths:
- 'terraform/**'
push:
branches: [main]
paths:
- 'terraform/**'
env:
TF_VERSION: 1.6.0
jobs:
checkov:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Checkov
uses: bridgecrewio/checkov-action@master
with:
directory: ./terraform
framework: terraform
output_format: cli
soft_fail: false
tfsec:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run tfsec
uses: aquasecurity/tfsec-sarif-action@v0.1.4
with:
sarif_file: tfsec.sarif
working_directory: ./terraform
- name: Upload SARIF
uses: github/codeql-action/upload-sarif@v2
with:
sarif_file: tfsec.sarif
infracost:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- 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: Comment Infracost
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const costReport = fs.readFileSync('infracost.txt', 'utf8');
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `## Cost Estimate\n\`\`\`\n${costReport}\n\`\`\``
});
plan:
runs-on: ubuntu-latest
needs: [checkov, tfsec]
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v2
- name: Configure AWS
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: Terraform Plan
id: plan
run: terraform plan -no-color
working-directory: ./terraform
- name: Comment Plan
uses: actions/github-script@v7
env:
PLAN: ${{ steps.plan.outputs.stdout }}
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `## Terraform Plan\n\`\`\`\n${process.env.PLAN}\n\`\`\``
});
apply:
runs-on: ubuntu-latest
needs: plan
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
environment:
name: production
url: https://myapp.example.com
steps:
- uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v2
- name: Configure AWS
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: Terraform Apply
id: apply
run: terraform apply -auto-approve
working-directory: ./terraform
- name: Notify
if: success()
uses: slackapi/slack-github-action@v1
with:
payload: |
{
"text": "✅ Production infrastructure updated"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
- name: Notify on Failure
if: failure()
uses: slackapi/slack-github-action@v1
with:
payload: |
{
"text": "❌ Production apply failed!"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
Complete production Terraform pipeline
5. Common Errors & Solutions
6. Summary Checklist
7. Next Steps
Next category: AWS Cloud - Cloud Computing Fundamentals
Comments (0)
This is exactly what I needed! The initContainer approach solved our migration issues completely. Thanks for the detailed guide!
ReplyLeave a Comment