Running Terraform manually works for small teams, but production infrastructure needs automated pipelines. This lesson covers integrating Terraform into CI/CD for safe, reviewable infrastructure changes.
Why Automate Terraform?
Manual terraform apply has risks:
- Someone applies from a stale branch
- Someone applies without the team reviewing the plan
- Someone forgets to commit their changes
- Different people have different AWS credentials with different permissions
CI/CD solves all of this by making infrastructure changes go through the same process as code changes: branch, PR, review, merge, deploy.
The GitOps Workflow for Infrastructure
1. Developer creates a branch
2. Makes infrastructure changes in .tf files
3. Opens a pull request
4. CI runs terraform plan and posts the output to the PR
5. Team reviews the plan
6. PR is approved and merged
7. CI runs terraform apply on the main branchThis ensures every infrastructure change is:
- Reviewed before it's applied
- Tracked in Git history
- Reproducible from the code
- Auditable through PR comments
GitHub Actions Workflow
Plan on Pull Request
# .github/workflows/terraform-plan.yml
name: Terraform Plan
on:
pull_request:
paths:
- 'infrastructure/**'
permissions:
contents: read
pull-requests: write
jobs:
plan:
runs-on: ubuntu-latest
defaults:
run:
working-directory: infrastructure
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.10.0
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
- name: Terraform Init
run: terraform init
- name: Terraform Format Check
run: terraform fmt -check
- name: Terraform Validate
run: terraform validate
- name: Terraform Plan
id: plan
run: terraform plan -no-color -out=tfplan
continue-on-error: true
- name: Post Plan to PR
uses: actions/github-script@v7
with:
script: |
const output = `#### Terraform Plan ๐
\`\`\`
${{ 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: Fail if plan failed
if: steps.plan.outcome == 'failure'
run: exit 1Apply on Merge
# .github/workflows/terraform-apply.yml
name: Terraform Apply
on:
push:
branches: [main]
paths:
- 'infrastructure/**'
jobs:
apply:
runs-on: ubuntu-latest
environment: production
defaults:
run:
working-directory: infrastructure
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.10.0
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
- name: Terraform Init
run: terraform init
- name: Terraform Apply
run: terraform apply -auto-approveKey Details
- OIDC authentication โ use
role-to-assumeinstead of storing AWS keys as secrets - Environment protection โ the
environment: productionsetting requires manual approval in GitHub - Path filtering โ only trigger when infrastructure files change
- Plan output in PR โ reviewers see exactly what will change
OIDC Authentication
Never store long-lived AWS credentials in GitHub Secrets. Use OIDC (OpenID Connect) instead:
# Create the 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"]
}
# Create a role GitHub Actions can assume
resource "aws_iam_role" "github_actions" {
name = "github-actions-terraform"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = {
Federated = aws_iam_openid_connect_provider.github.arn
}
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
}
StringLike = {
"token.actions.githubusercontent.com:sub" = "repo:my-org/my-repo:*"
}
}
}]
})
}This gives GitHub Actions temporary credentials that expire automatically. No secrets to rotate.
Policy as Code
Policy as code validates your Terraform plans against rules before applying.
Open Policy Agent (OPA)
OPA uses the Rego language to define policies:
# policy/no-public-s3.rego
package terraform
deny[msg] {
resource := input.planned_values.root_module.resources[_]
resource.type == "aws_s3_bucket_acl"
resource.values.acl == "public-read"
msg := sprintf("S3 bucket '%s' must not be public", [resource.name])
}
deny[msg] {
resource := input.planned_values.root_module.resources[_]
resource.type == "aws_instance"
not resource.values.tags.Environment
msg := sprintf("Instance '%s' must have an Environment tag", [resource.name])
}Run it in CI:
- name: Terraform Plan (JSON)
run: |
terraform plan -out=tfplan
terraform show -json tfplan > plan.json
- name: OPA Policy Check
run: |
opa eval --input plan.json --data policy/ \
--format pretty "data.terraform.deny"Sentinel (Terraform Cloud)
Sentinel is HashiCorp's built-in policy framework:
# policies/require-tags.sentinel
import "tfplan/v2" as tfplan
mandatory_tags = ["Environment", "Team", "CostCenter"]
allInstances = filter tfplan.resource_changes as _, rc {
rc.type is "aws_instance" and
rc.mode is "managed" and
(rc.change.actions contains "create" or rc.change.actions contains "update")
}
main = rule {
all allInstances as _, instance {
all mandatory_tags as tag {
instance.change.after.tags contains tag
}
}
}Cost Estimation
Catch expensive infrastructure changes before they're applied:
- name: Infracost
uses: infracost/actions/setup@v3
with:
api-key: ${{ secrets.INFRACOST_API_KEY }}
- name: Generate cost diff
run: |
infracost diff --path=infrastructure \
--format=json --out-file=/tmp/infracost.json
- name: Post cost comment
uses: infracost/actions/comment@v3
with:
path: /tmp/infracost.json
behavior: updateThis posts a cost breakdown to your PR:
Monthly cost will increase by $127.40 (+15%)
+ aws_instance.web $62.05/mo
+ aws_rds_instance.db $65.35/mo
~ aws_s3_bucket.assets $0.00/mo (no change)Drift Detection
Schedule regular drift detection to catch manual changes:
# .github/workflows/drift-detection.yml
name: Drift Detection
on:
schedule:
- cron: '0 8 * * 1' # Every Monday at 8am
jobs:
detect-drift:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- name: Terraform Plan
id: plan
run: terraform plan -detailed-exitcode -no-color
continue-on-error: true
- name: Alert on drift
if: steps.plan.outputs.exitcode == '2'
run: |
echo "Infrastructure drift detected!"
# Send Slack notification, create GitHub issue, etc.Exit code 2 from terraform plan -detailed-exitcode means changes are needed โ something drifted.
Summary
- Automate Terraform with GitHub Actions: plan on PR, apply on merge
- Use OIDC for authentication โ never store long-lived cloud credentials
- Post plan output to PRs so reviewers see exactly what changes
- Enforce policies with OPA or Sentinel before applying
- Use Infracost to catch expensive changes in PRs
- Schedule drift detection to find manual changes