Terraform Deploy โ AWS
Two-workflow pattern: plan on PR (posts output as a PR comment), apply on merge to main. Uses OIDC โ no long-lived AWS credentials stored in GitHub secrets.
Prerequisitesโ
AWS OIDC Setupโ
Configure your AWS account to trust GitHub's OIDC provider once:
# IAM OIDC provider (create once per account)
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = [] # AWS validates via its own CA store (provider v5+)
}
# IAM role that GitHub Actions assumes
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 = {
StringLike = {
"token.actions.githubusercontent.com:sub" = "repo:YOUR_ORG/YOUR_REPO:*"
}
}
}]
})
}
Required Repo Secrets/Variablesโ
| Name | Type | Value |
|---|---|---|
AWS_ROLE_TO_ASSUME | Secret | ARN of the IAM role above |
TF_STATE_BUCKET | Secret | S3 bucket name for Terraform state |
TF_LOCK_TABLE | Secret | DynamoDB table name for state locking |
AWS_REGION | Variable | e.g. eu-west-1 |
Workflow 1 โ Plan on PRโ
name: Terraform Plan
on:
pull_request:
branches: [main]
paths:
- 'terraform/**'
permissions:
id-token: write # required for OIDC
contents: read
pull-requests: write # required to post PR comment
jobs:
plan:
runs-on: ubuntu-latest
defaults:
run:
working-directory: terraform/
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }}
aws-region: ${{ vars.AWS_REGION }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.8.0"
- name: Terraform Init
run: |
terraform init \
-backend-config="bucket=${{ secrets.TF_STATE_BUCKET }}" \
-backend-config="dynamodb_table=${{ secrets.TF_LOCK_TABLE }}" \
-backend-config="region=${{ vars.AWS_REGION }}"
- name: Terraform Validate
run: terraform validate
- name: Terraform Plan
id: plan
run: |
set -o pipefail
terraform plan -no-color -out=tfplan 2>&1 | tee plan-output.txt
continue-on-error: true # capture output even if plan fails
- name: Post plan as PR comment
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const plan = fs.readFileSync('terraform/plan-output.txt', 'utf8');
const truncated = plan.length > 65000 ? plan.slice(0, 65000) + '\n...(truncated)' : plan;
const body = `## Terraform Plan\n\`\`\`hcl\n${truncated}\n\`\`\``;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body
});
- name: Fail if plan failed
if: steps.plan.outcome == 'failure'
run: exit 1
- name: Upload plan artifact
uses: actions/upload-artifact@v4
with:
name: tfplan
path: terraform/tfplan
retention-days: 1
Workflow 2 โ Apply on Mergeโ
name: Terraform Apply
on:
push:
branches: [main]
paths:
- 'terraform/**'
permissions:
id-token: write
contents: read
jobs:
apply:
runs-on: ubuntu-latest
environment: production # requires manual approval if configured in GitHub
defaults:
run:
working-directory: terraform/
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }}
aws-region: ${{ vars.AWS_REGION }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.8.0"
- name: Terraform Init
run: |
terraform init \
-backend-config="bucket=${{ secrets.TF_STATE_BUCKET }}" \
-backend-config="dynamodb_table=${{ secrets.TF_LOCK_TABLE }}" \
-backend-config="region=${{ vars.AWS_REGION }}"
- name: Download plan artifact
uses: actions/download-artifact@v4
with:
name: tfplan
path: terraform/
- name: Terraform Apply
run: terraform apply -auto-approve tfplan
Tipsโ
- The
environment: productiongate lets you require manual approval beforeapplyruns โ configure this in repo โ Settings โ Environments. paths: ['terraform/**']ensures the workflow only triggers when Terraform files change.- The plan artifact has
retention-days: 1โ it's a short-lived bridge between the PR and the merge, not a long-term store. continue-on-error: trueon the Plan step lets the PR comment post even when the plan itself fails, so you see the error output in context.