CI/CD Pipelines
Automate Terraform deployments with GitHub Actions and other CI tools
CI/CD Pipelines
In the previous tutorial, we learned how to debug Terraform like a pro. Now let's level up to the final boss: automating everything.
Running Terraform from your laptop doesn't scale. Teams need automated pipelines with reviews, approvals, and audit trails. Let's set up proper automation so you can deploy infrastructure with confidence.
Why Automate?
"Can't I just keep running terraform apply from my machine?"
Sure, if you like chaos. Here's why automation wins:
- Consistency — Same process every time, no "oops I forgot to init"
- Review — PRs show exactly what will change
- Audit — Track who changed what, when
- Safety — No accidental applies from the wrong directory
- Speed — Automatic deploys on merge
GitOps Workflow
The basic idea is beautifully simple:
1. Create feature branch
2. Make Terraform changes
3. Open PR → Pipeline runs terraform plan
4. Review plan output in PR
5. Approve and merge → Pipeline runs terraform apply
GitHub Actions
"Where do I start?"
GitHub Actions is the most popular choice. Let's build up from simple to production-ready.
Basic Workflow
# .github/workflows/terraform.yml
name: Terraform
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
terraform:
runs-on: ubuntu-latest
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.6.0"
- name: Terraform Init
run: terraform init
- name: Terraform Format Check
run: terraform fmt -check
- name: Terraform Validate
run: terraform validate
- name: Terraform Plan
run: terraform plan -out=tfplan
- name: Terraform Apply
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: terraform apply -auto-approve tfplan
Plan Output in PR
"Can I see the plan output right in the PR?"
Absolutely! This is where it gets really cool:
# .github/workflows/terraform.yml
name: Terraform
on:
pull_request:
branches: [main]
jobs:
plan:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- name: Terraform Init
run: terraform init
- name: Terraform Plan
id: plan
run: terraform plan -no-color
continue-on-error: true
- name: Comment Plan on PR
uses: actions/github-script@v7
with:
script: |
const output = `#### Terraform Plan 📖
\`\`\`hcl
${{ steps.plan.outputs.stdout }}
\`\`\`
*Pushed by: @${{ github.actor }}*`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: output
});
- name: Plan Status
if: steps.plan.outcome == 'failure'
run: exit 1
Multi-Environment Pipeline
"What about dev, staging, and prod?"
Use a matrix strategy to plan across all environments:
# .github/workflows/terraform.yml
name: Terraform
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
plan:
runs-on: ubuntu-latest
strategy:
matrix:
environment: [dev, staging, prod]
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- name: Terraform Init
working-directory: environments/${{ matrix.environment }}
run: terraform init
- name: Terraform Plan
working-directory: environments/${{ matrix.environment }}
run: terraform plan -out=tfplan
env:
AWS_ACCESS_KEY_ID: ${{ secrets[format('AWS_ACCESS_KEY_ID_{0}', matrix.environment)] }}
AWS_SECRET_ACCESS_KEY: ${{ secrets[format('AWS_SECRET_ACCESS_KEY_{0}', matrix.environment)] }}
- uses: actions/upload-artifact@v4
with:
name: tfplan-${{ matrix.environment }}
path: environments/${{ matrix.environment }}/tfplan
apply:
needs: plan
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment: production # Requires approval
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- uses: actions/download-artifact@v4
with:
name: tfplan-prod
path: environments/prod
- name: Terraform Init
working-directory: environments/prod
run: terraform init
- name: Terraform Apply
working-directory: environments/prod
run: terraform apply -auto-approve tfplan
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID_prod }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY_prod }}
GitHub Environments
"How do I require approval before deploying to production?"
GitHub Environments to the rescue:
- Go to repo Settings → Environments
- Create
productionenvironment - Add required reviewers
- Set deployment branch rules
Now nobody can apply to prod without approval. How cool is that?
jobs:
apply:
environment: production # Waits for approval
steps:
- run: terraform apply
OIDC Authentication (Recommended)
"Storing AWS credentials as GitHub secrets feels wrong."
It is. Use OIDC instead — no long-lived credentials needed.
AWS Setup
# Create OIDC provider in AWS
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}
# Role for GitHub Actions
resource "aws_iam_role" "github_actions" {
name = "github-actions-terraform"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRoleWithWebIdentity"
Effect = "Allow"
Principal = {
Federated = aws_iam_openid_connect_provider.github.arn
}
Condition = {
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
}
StringLike = {
"token.actions.githubusercontent.com:sub" = "repo:your-org/your-repo:*"
}
}
}]
})
}
resource "aws_iam_role_policy_attachment" "github_actions" {
role = aws_iam_role.github_actions.name
policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess" # Scope down!
}
GitHub Actions with OIDC
jobs:
terraform:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-terraform
aws-region: us-west-2
- uses: hashicorp/setup-terraform@v3
- run: terraform init
- run: terraform apply -auto-approve
No secrets needed! Credentials are short-lived and scoped. This is the way.
Terraform Cloud
"Is there a managed service for all this?"
HashiCorp's Terraform Cloud handles state, plans, applies, and approvals for you:
Setup
# main.tf
terraform {
cloud {
organization = "your-org"
workspaces {
name = "my-app-prod"
}
}
}
GitHub Actions with Terraform Cloud
jobs:
terraform:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
cli_config_credentials_token: ${{ secrets.TF_API_TOKEN }}
- name: Terraform Init
run: terraform init
- name: Terraform Plan
run: terraform plan
# Plan runs in Terraform Cloud
- name: Terraform Apply
if: github.ref == 'refs/heads/main'
run: terraform apply -auto-approve
# Apply runs in Terraform Cloud
VCS Integration
Or skip GitHub Actions entirely and connect Terraform Cloud directly to GitHub:
- Create workspace in Terraform Cloud
- Connect to GitHub repo
- Configure auto-apply or manual approval
PRs automatically show plan. Merge triggers apply. Zero pipeline code needed!
GitLab CI
"What if I'm using GitLab?"
GitLab CI works great too:
# .gitlab-ci.yml
stages:
- validate
- plan
- apply
variables:
TF_ROOT: ${CI_PROJECT_DIR}
image:
name: hashicorp/terraform:1.6
entrypoint: [""]
cache:
paths:
- ${TF_ROOT}/.terraform
before_script:
- cd ${TF_ROOT}
- terraform init
validate:
stage: validate
script:
- terraform validate
- terraform fmt -check
plan:
stage: plan
script:
- terraform plan -out=tfplan
artifacts:
paths:
- tfplan
apply:
stage: apply
script:
- terraform apply -auto-approve tfplan
dependencies:
- plan
when: manual
only:
- main
Atlantis
"What if I want something self-hosted that works through PR comments?"
Atlantis is a self-hosted PR automation tool — you just type atlantis plan in a comment and it runs:
Deployment
# docker-compose.yml
version: '3'
services:
atlantis:
image: ghcr.io/runatlantis/atlantis:latest
ports:
- "4141:4141"
environment:
- ATLANTIS_GH_USER=atlantis-bot
- ATLANTIS_GH_TOKEN=${GITHUB_TOKEN}
- ATLANTIS_GH_WEBHOOK_SECRET=${WEBHOOK_SECRET}
- ATLANTIS_REPO_ALLOWLIST=github.com/your-org/*
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
Usage
In PR comments (it's like magic):
atlantis plan # Run terraform plan
atlantis apply # Run terraform apply (after approval)
atlantis plan -d . # Plan specific directory
Configuration
# atlantis.yaml (in repo root)
version: 3
projects:
- name: production
dir: environments/prod
workflow: default
autoplan:
enabled: true
when_modified:
- "*.tf"
- "../modules/**/*.tf"
apply_requirements:
- approved
- mergeable
- name: development
dir: environments/dev
workflow: default
autoplan:
enabled: true
Security Best Practices
"How do I make sure this is actually secure?"
Great question. Automation without security is just fast chaos.
Least Privilege
Give CI only the permissions it needs — nothing more:
# Minimal permissions for CI
data "aws_iam_policy_document" "terraform_ci" {
statement {
actions = [
"ec2:*",
"rds:*",
"s3:*",
# Only what Terraform needs
]
resources = ["*"]
}
# State bucket access
statement {
actions = [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject"
]
resources = ["arn:aws:s3:::my-terraform-state/*"]
}
statement {
actions = [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:DeleteItem"
]
resources = ["arn:aws:dynamodb:*:*:table/terraform-locks"]
}
}
Secret Management
"Where do I put my database passwords?"
Never in code. Use secrets management:
# Use GitHub secrets
env:
TF_VAR_db_password: ${{ secrets.DB_PASSWORD }}
# Or fetch from vault
- name: Get Secrets
uses: hashicorp/vault-action@v2
with:
url: https://vault.example.com
method: github
secrets: |
secret/data/terraform db_password | TF_VAR_db_password
Branch Protection
Non-negotiable:
- Require PR reviews
- Require status checks (plan must pass)
- Prevent direct pushes to main
Seriously, turn these on right now if you haven't already.
Pipeline Best Practices
Here are the patterns that'll keep you out of trouble.
1. Always Plan First
Never apply without saving a plan first:
- name: Plan
run: terraform plan -out=tfplan
- name: Apply
run: terraform apply tfplan # Use saved plan
2. Lock State During CI
"What if two pipelines run at the same time?"
Bad things. Use concurrency groups:
# Prevent concurrent runs
concurrency:
group: terraform-${{ github.ref }}
cancel-in-progress: false
3. Fail on Drift
- name: Check for Drift
run: |
terraform plan -detailed-exitcode
# Exit code 2 = changes detected
4. Notify on Failure
- name: Notify Slack
if: failure()
uses: slackapi/slack-github-action@v1
with:
channel-id: 'deployments'
slack-message: 'Terraform failed: ${{ github.event.pull_request.html_url }}'
5. Cost Estimation
"Can I see how much this will cost before I deploy?"
Infracost shows cost estimates right in your PR:
- name: Infracost
uses: infracost/actions/setup@v2
- name: Cost Estimate
run: |
infracost breakdown --path=. --format=json --out-file=infracost.json
infracost comment github --path=infracost.json \
--repo=$GITHUB_REPOSITORY \
--pull-request=${{ github.event.pull_request.number }}
Complete Production Pipeline
Here's the whole enchilada — validation, security scanning, planning with PR comments, and gated production apply:
# .github/workflows/terraform.yml
name: Terraform
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
pull-requests: write
id-token: write
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform fmt -check -recursive
- run: terraform init -backend=false
- run: terraform validate
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: bridgecrewio/checkov-action@master
with:
directory: .
framework: terraform
plan:
needs: [validate, security]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-west-2
- uses: hashicorp/setup-terraform@v3
- name: Terraform Init
run: terraform init
- name: Terraform Plan
id: plan
run: terraform plan -no-color -out=tfplan
continue-on-error: true
- name: Comment Plan
uses: actions/github-script@v7
if: github.event_name == 'pull_request'
with:
script: |
const plan = `${{ steps.plan.outputs.stdout }}`;
const truncated = plan.length > 65000
? plan.substring(0, 65000) + '\n\n... (truncated)'
: plan;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `### Terraform Plan\n\`\`\`hcl\n${truncated}\n\`\`\``
});
- uses: actions/upload-artifact@v4
with:
name: tfplan
path: tfplan
- name: Check Plan Status
if: steps.plan.outcome == 'failure'
run: exit 1
apply:
needs: plan
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment: production
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-west-2
- uses: hashicorp/setup-terraform@v3
- uses: actions/download-artifact@v4
with:
name: tfplan
- name: Terraform Init
run: terraform init
- name: Terraform Apply
run: terraform apply -auto-approve tfplan
Congratulations! 🎉
You did it! You've completed the entire Terraform tutorial series. That's 15 tutorials from "what is Terraform?" to fully automated CI/CD pipelines.
Here's everything you've learned along the way:
- Writing Terraform configurations from scratch
- Mastering HCL syntax and expressions
- Managing state safely (remote backends, locking, the works)
- Creating reusable modules
- Handling secrets without getting your company on the news
- Managing multiple environments
- Importing existing resources
- Testing and validating infrastructure
- Debugging when things go sideways
- Automating everything with CI/CD
You went from zero to production-ready. That's seriously impressive.
Now go build something awesome! 🚀