Why Terraform in CI/CD matters
Deployments that happen outside version control are a liability. When infrastructure changes live in someone's terminal history or a manual Jenkins job, you lose the audit trail, the peer review, and the reproducibility that make cloud operations reliable. Terraform in CI/CD with GitHub Actions, explained simply means automating your IaC workflow so that every infrastructure change flows through pull requests, gets reviewed, and only applies after approval. This article walks through that exact pattern—one we've used in production to gate changes safely and maintain visibility across e-commerce platforms, SaaS backends, and trading systems.
Authenticate with OIDC — No long-lived AWS keys in GitHub
The old way was to store AWS access keys as GitHub secrets. It works, but those keys are long-lived, static, and a data breach away from catastrophe. OIDC (OpenID Connect) flips the model: GitHub's CI job proves its identity to AWS using a time-limited token, and AWS grants just enough permission for that one job.
First, set up the OIDC provider in AWS. This is a one-time account setup:
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}
resource "aws_iam_role" "github_actions" {
name = "github-actions-terraform-role"
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:your-org/your-repo:ref:refs/heads/main"
}
}
}
]
})
}
resource "aws_iam_role_policy" "terraform_policy" {
role = aws_iam_role.github_actions.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"ec2:*",
"s3:*",
"dynamodb:*",
"lambda:*",
"logs:*"
]
Resource = "*"
},
{
Effect = "Allow"
Action = [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:UpdateItem"
]
Resource = "arn:aws:dynamodb:*:*:table/terraform-locks"
}
]
})
}
Then in your GitHub Actions workflow, request the OIDC token and exchange it for AWS credentials:
name: Terraform Plan and Apply
on:
pull_request:
paths:
- 'terraform/**'
- '.github/workflows/terraform.yml'
push:
branches:
- main
paths:
- 'terraform/**'
env:
AWS_ROLE_ARN: arn:aws:iam::123456789012:role/github-actions-terraform-role
AWS_REGION: us-east-1
jobs:
plan:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
id-token: write
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ env.AWS_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@v2
with:
terraform_version: 1.6.0
- name: Terraform Init
run: terraform -chdir=terraform init
- name: Terraform Plan
id: plan
run: |
terraform -chdir=terraform plan -no-color -out=tfplan
No secrets stored. No key rotation headaches. AWS sees the GitHub repository in the token and only allows actions from that repo to assume the role.
Plan on pull request — Post the plan as a PR comment for review
When someone opens a pull request with infrastructure changes, the workflow should run terraform plan and post the output so reviewers can see exactly what will change. This is your first line of defense.
Continue the workflow after the plan step:
- name: Save Plan
run: |
terraform -chdir=terraform show -no-color tfplan > tfplan.txt
- name: Post Plan to PR
uses: actions/github-script@v7
if: github.event_name == 'pull_request'
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const fs = require('fs');
const plan = fs.readFileSync('tfplan.txt', 'utf8');
const output = `## Terraform Plan
\`\`\`
${plan}
\`\`\`
`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: output
});
Now reviewers see the plan before anything touches production. They can spot resource deletions, surprising changes, or missing updates. This human review loop catches automation mistakes and enforces team standards.
Apply on merge — Apply the reviewed plan to the target environment
Only after a pull request is merged and the code reaches your main branch should terraform apply run. This enforces the pattern: review first, apply second.
Add an apply job that runs only on push to main:
apply:
runs-on: ubuntu-latest
needs: plan
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ env.AWS_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@v2
with:
terraform_version: 1.6.0
- name: Terraform Init
run: terraform -chdir=terraform init
- name: Terraform Apply
run: terraform -chdir=terraform apply -auto-approve
- name: Notify Slack
if: always()
run: |
curl -X POST ${{ secrets.SLACK_WEBHOOK }} \
-H 'Content-Type: application/json' \
-d '{
"text": "Terraform apply completed on main branch"
}'
The needs: plan ensures the plan job passes before apply runs. The if condition gates execution to only main branch pushes. This keeps staging and dev environments from applying on every PR, and production from accidentally running apply on branch pushes.
Protect production — Require approvals for prod environment jobs
For production infrastructure, you want a second gate: the deploy job must wait for a human approval. GitHub Actions supports environment protection rules that block job execution until someone clicks "approve".
First, define environments in your workflow:
apply-prod:
runs-on: ubuntu-latest
needs: plan
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment:
name: production
url: https://console.aws.amazon.com
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-terraform-prod-role
aws-region: ${{ env.AWS_REGION }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@v2
with:
terraform_version: 1.6.0
- name: Terraform Init
run: terraform -chdir=terraform init -backend-config="key=prod.tfstate"
- name: Terraform Apply
run: terraform -chdir=terraform apply -auto-approve
- name: Log Deployment
run: |
echo "Production deployment at $(date)" >> deployment.log
Then in your GitHub repository settings, go to Environments → Production and add deployment protection rules requiring approval. Now whenever the apply-prod job runs, GitHub will pause and wait for a reviewer to approve before the job continues.
For maximum safety, restrict who can approve:
- Set
Required reviewersto a specific team (e.g., Platform Engineers). - Set
Deployment branchesto allow only main or release branches. - Enable branch protection on main so only reviewed PRs can merge.
This layering—OIDC auth, PR plans, branch protection, environment approvals—creates a workflow where infrastructure changes are visible, reviewed, and approved at every step.
Common mistakes
Storing AWS keys as plain secrets. Even if it works today, one compromised developer account puts your infrastructure at risk. Use OIDC.
Applying on every PR, not just main. The if condition on the apply job is critical. Without it, you'll apply changes multiple times or to the wrong environment.
Forgetting to lock state during CI runs. If two GitHub Actions jobs try to modify the same Terraform state, you'll get corruption. Always use remote state with locking (DynamoDB for S3 backends).
Over-permissive OIDC policies. The IAM role should allow only what Terraform needs for that environment. A dev CI role shouldn't touch production resources.
Skipping the plan review. Posting the plan to a PR comment is only useful if someone actually reads it. Enforce code owners or required approvals on IaC files so the review happens.
Key takeaways
- Use OIDC for GitHub Actions to AWS authentication—no long-lived secrets, time-limited tokens only.
- Run
terraform planon pull requests and post the output as a PR comment for human review. - Run
terraform applyonly when code merges to main, with branch protection requiring pull request reviews. - Add environment protection rules in GitHub to require approval before production deployments.
- Lock Terraform state in remote backends (S3 + DynamoDB) and ensure only CI has write access.
- Test your OIDC setup in a staging environment before rolling out to production.
- Log all deployments and integrate with Slack or PagerDuty for visibility across the team.






Comments
Loading comments…