GitHub Actions
GitHub Actions is the most common way to run Terraform in CI when your code already lives on GitHub. The pattern is simple and powerful: every pull request runs fmt, validate, and plan, and posts the plan as a comment so reviewers can see exactly what will change. Once the PR merges to main, a second job runs apply to roll that change out. Authenticating to your cloud through OpenID Connect (OIDC) means you never store long-lived access keys as secrets — the workflow exchanges a short-lived GitHub token for temporary cloud credentials. Everything here works identically with OpenTofu by swapping the setup action.
How the pipeline is structured
A robust Terraform pipeline separates read-only work from mutating work using triggers. The plan job runs on pull_request events, where it can read state and compute a diff but never changes anything. The apply job runs on push to main (the merge event), gated behind the merged PR. This split is what makes the workflow safe: a malicious or mistaken PR can produce a plan for review, but only code that has actually merged can apply.
| Stage | Trigger | What it does | Permissions needed |
|---|---|---|---|
| fmt / validate | pull_request | Style and syntax gates | Read only |
| plan | pull_request | Compute diff, comment on PR | Read state, write PR comments |
| apply | push to main | Apply approved changes | Read/write state, cloud write |
Authenticating with OIDC
OIDC lets GitHub Actions assume an IAM role directly, with no stored secret keys. You create an IAM OIDC identity provider for token.actions.githubusercontent.com and a role whose trust policy restricts which repository and branch may assume it. The role is referenced by ARN in the workflow — an ARN is not a secret, so it can live in plaintext.
data "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
}
resource "aws_iam_role" "terraform_ci" {
name = "terraform-ci"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = {
Federated = data.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:devcraftly/infra:*"
}
}
}]
})
}
Tighten the
subcondition as far as you can. Restricting torepo:devcraftly/infra:ref:refs/heads/mainmeans only workflows running on themainbranch can assume the apply role, so a fork or feature branch cannot.
The plan job (on pull requests)
This job formats, validates, and plans, then posts the plan output back to the PR. The permissions block grants id-token: write (required for OIDC) and pull-requests: write (to comment). The cloud credentials are fetched by aws-actions/configure-aws-credentials, which performs the OIDC exchange against the role ARN.
name: terraform
on:
pull_request:
branches: [main]
push:
branches: [main]
permissions:
id-token: write
contents: read
pull-requests: write
jobs:
plan:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/terraform-ci
aws-region: us-east-1
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.9.5
- run: terraform fmt -check -recursive
- run: terraform init
- run: terraform validate
- id: plan
run: terraform plan -no-color -out=tfplan
continue-on-error: true
- uses: actions/github-script@v7
with:
script: |
const body = `#### Terraform Plan 📖\n\`\`\`\n${{ steps.plan.outputs.stdout }}\n\`\`\``;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: body.substring(0, 65000)
});
- if: steps.plan.outcome == 'failure'
run: exit 1
When the plan runs, the action streams Terraform’s familiar output, which is also what lands in the PR comment.
Output:
Terraform will perform the following actions:
# aws_s3_bucket.assets will be created
+ resource "aws_s3_bucket" "assets" {
+ bucket = "devcraftly-assets"
+ id = (known after apply)
}
Plan: 1 to add, 0 to change, 0 to destroy.
The apply job (on merge)
The apply job is identical in setup but runs only on push to main. It assumes a role with write permissions and applies with -auto-approve, since the human approval already happened during PR review. Wrapping it in a GitHub environment (production) lets you require a manual approval or restrict who can trigger it.
apply:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/terraform-ci
aws-region: us-east-1
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.9.5
- run: terraform init
- run: terraform apply -auto-approve -no-color
Output:
aws_s3_bucket.assets: Creating...
aws_s3_bucket.assets: Creation complete after 2s [id=devcraftly-assets]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
Secrets and variables
Use OIDC for cloud auth so there are no cloud keys to store. The secrets you do need — a provider API token for a SaaS, a database password, a TF_TOKEN_app_terraform_io for remote state — go in repository secrets and are exposed as environment variables. Terraform automatically reads any environment variable prefixed TF_VAR_ as an input variable.
- run: terraform apply -auto-approve
env:
TF_VAR_db_password: ${{ secrets.DB_PASSWORD }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
Never
echoa secret or pass it on a command line where it could be logged. GitHub masks registered secrets in logs, butTF_VAR_values and provider tokens belong inenv, not inrunarguments.
Best Practices
- Authenticate with OIDC and assume an IAM role; never store static cloud access keys as secrets.
- Run
fmt -check,validate, andplanon every pull request, and post the plan as a comment for review. - Apply only on
pushtomainso unmerged code can never touch real infrastructure. - Pin
terraform_versionand action versions (@v4, not@main) for reproducible, supply-chain-safe runs. - Set
permissionsto the minimum each job needs —id-token: writefor OIDC,pull-requests: writeonly on the plan job. - Gate the apply job behind a GitHub
environmentto add required reviewers and deployment protection rules. - Use a remote backend with state locking so concurrent workflow runs cannot corrupt state.