Skip to main content

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โ€‹

NameTypeValue
AWS_ROLE_TO_ASSUMESecretARN of the IAM role above
TF_STATE_BUCKETSecretS3 bucket name for Terraform state
TF_LOCK_TABLESecretDynamoDB table name for state locking
AWS_REGIONVariablee.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: production gate lets you require manual approval before apply runs โ€” 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: true on the Plan step lets the PR comment post even when the plan itself fails, so you see the error output in context.