
Automating Terraform with GitHub Actions
Running terraform apply from a laptop works fine when it's just me on a small project, but the moment a team is involved, or infrastructure grows past a handful of resources, manual runs become a liability. Someone forgets to run plan first, applies on the wrong branch, or uses stale credentials. CI/CD for Terraform solves all of that by turning infrastructure changes into the same pull request workflow I already use for application code.
In this guide, I'll walk through how to set up GitHub Actions to automate Terraform workflows. The setup uses two workflows: one that runs terraform plan on every pull request and posts the output as a PR comment, and another that runs terraform apply automatically when changes are merged into main.
Prerequisites
- A GitHub repository with your Terraform configuration
- An AWS account with programmatic access (access key and secret key)
- Terraform state stored in a remote backend (S3 + DynamoDB is what I use, but any remote backend works)
- Basic familiarity with GitHub Actions YAML syntax
Goals
- Automate
terraform planon every pull request so changes are reviewed before they're applied - Automate
terraform applyon merge tomainso infrastructure stays in sync with the repo - Post plan output directly to the PR for easy review
- Keep credentials out of the repository using GitHub Actions secrets
What Is CI/CD for Infrastructure?
CI/CD stands for Continuous Integration and Continuous Delivery. For application code, that usually means running tests, building artifacts, and deploying to a server. For infrastructure, the concept is the same but the steps are different:
- Continuous Integration: Every time someone pushes a change, Terraform validates the configuration and generates a plan. This catches syntax errors, missing variables, and unexpected resource changes early.
- Continuous Delivery: When the change is approved and merged, Terraform applies it automatically. No one needs to run commands locally.
The benefit is consistency. Every change goes through the same pipeline, every plan is visible in the PR, and there's a full audit trail in GitHub of what changed and when.
GitHub Actions Basics
GitHub Actions is GitHub's built-in CI/CD platform. Workflows are defined as YAML files inside .github/workflows/ in the repository. Each workflow has three main parts:
- A name that shows up in the Actions tab
- A trigger that defines when the workflow runs (
on: push,on: pull_request,on: schedule, etc.) - One or more jobs, each containing a series of steps that run sequentially
Workflows run on GitHub-hosted runners (Ubuntu, Windows, or macOS). For Terraform, ubuntu-latest is the standard choice.
Storing Secrets
Before setting up the workflows, the AWS credentials need to be stored as GitHub Actions secrets. Never commit credentials to the repository, not even in environment files that are gitignored, because they can still leak through logs or history.
Go to the repository on GitHub and navigate to Settings → Secrets and variables → Actions. Add two new repository secrets:
AWS_ACCESS_KEY_ID— the access key from your AWS IAM user or roleAWS_SECRET_ACCESS_KEY— the corresponding secret key
These are referenced in workflows as ${{ secrets.AWS_ACCESS_KEY_ID }} and ${{ secrets.AWS_SECRET_ACCESS_KEY }}. GitHub redacts them from workflow logs automatically.
Note: If you're using temporary credentials (e.g., from AWS SSO or an assumed role), you'll also need to add
AWS_SESSION_TOKENas a secret. For a more robust setup, consider using OIDC federation instead of static keys. I'll cover that at the end of this post.
Workflow 1: Terraform Plan on Pull Requests
This workflow runs every time a pull request is opened or updated against main. It validates the configuration, generates a plan, and posts the plan output as a comment on the PR. This way, I can review exactly what Terraform will change before approving the merge.
.github/workflows/terraform-plan.yml
name: Terraform Plan
on:
pull_request:
branches:
- main
jobs:
terraform-plan:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
- name: Terraform Format Check
run: terraform fmt -check
- name: Terraform Init
run: terraform init
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- name: Terraform Validate
run: terraform validate
- name: Terraform Plan
id: plan
run: terraform plan -no-color
continue-on-error: true
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- name: Post Plan to PR
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const botComment = comments.find(comment => {
return comment.user.type === 'Bot' && comment.body.includes('Terraform Plan Output')
});
const output = `#### Terraform Plan Output 📋
\`\`\`
${{ steps.plan.outputs.stdout }}
\`\`\`
*Plan result: \`${{ steps.plan.outcome }}\`*`;
if (botComment) {
github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
});
}
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 1Here's what each step does and why it matters:
actions/checkout@v4pulls the repo code into the runner so Terraform has access to the configuration files.hashicorp/setup-terraform@v3installs Terraform on the runner. It also wraps the Terraform binary to capturestdoutandstderras step outputs, which is how the plan output gets passed to the PR comment step.terraform fmt -checkverifies that all.tffiles follow the canonical format. If someone forgot to runterraform fmtlocally, this catches it. The-checkflag makes it exit with a non-zero code without modifying files.terraform initinitializes the backend and downloads provider plugins. The AWS credentials are needed here because the backend (S3, for example) requires authentication.terraform validatechecks the configuration for syntax errors and internal consistency. It doesn't need cloud credentials because it only looks at the local files.terraform plan -no-colorgenerates the execution plan. The-no-colorflag strips ANSI escape codes so the output renders cleanly in the PR comment.continue-on-error: trueensures the workflow doesn't stop here, so the PR comment step still runs even if the plan fails.- The
actions/github-scriptstep posts the plan output as a PR comment. It also deletes any previous plan comment from the same workflow to keep the PR timeline clean. TheGITHUB_TOKENis automatically provided by GitHub Actions, no need to create it manually. - The final step checks if the plan actually failed and exits with an error code if it did. This ensures the PR shows a failing check when the plan has issues.
Note: The
permissionsblock at the job level is important.contents: readlets the workflow check out the code, andpull-requests: writegrants permission to post comments on the PR.
Workflow 2: Terraform Apply on Merge
This workflow triggers when a pull request is merged into main (i.e., on a push to main). It initializes Terraform and applies the changes automatically.
.github/workflows/terraform-apply.yml
name: Terraform Apply
on:
push:
branches:
- main
jobs:
terraform-apply:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
- name: Terraform Init
run: terraform init
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- name: Terraform Apply
run: terraform apply -auto-approve
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}This workflow is intentionally simpler. By the time code reaches main, the plan has already been reviewed in the PR. The -auto-approve flag skips the interactive confirmation prompt since there's no terminal to type "yes" into.
Note: You might wonder why there's no
terraform planstep here. Since the plan was already reviewed in the PR workflow, running it again would just add time. The apply step generates its own plan internally before applying. If the state has drifted between the PR review and the merge, Terraform will detect it and the apply will reflect the current state, not the old plan.
How It All Fits Together
Here's the full flow:
- I create a branch and make changes to the Terraform configuration.
- I open a pull request against
main. - The Plan workflow runs automatically. It validates the config, generates a plan, and posts the output as a PR comment.
- I (or a teammate) review the plan in the PR. If something looks off, I push a fix and the workflow runs again.
- Once the plan looks good, I merge the PR.
- The Apply workflow triggers on the push to
mainand provisions or updates the infrastructure.
No manual terraform apply. No risk of applying on the wrong branch. Every change is tracked in Git history and every plan is visible in the PR.
Remote State Backend
These workflows assume Terraform state is stored remotely. If the state file lives locally on your machine, the GitHub Actions runner won't have access to it and terraform plan will think every resource needs to be created from scratch.
I use S3 with DynamoDB for state locking. Here's the backend configuration:
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "infra/terraform.tfstate"
region = "eu-west-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}The dynamodb_table field enables state locking, which prevents two concurrent apply runs from corrupting the state file. The encrypt flag enables server-side encryption on the S3 object.
Using OIDC Instead of Static Keys
Static AWS access keys work, but they come with downsides: they don't expire unless you rotate them manually, and they grant access to anyone who has them. A better approach is OIDC (OpenID Connect) federation, which lets GitHub Actions assume an AWS IAM role directly without any long-lived credentials.
The setup involves three things:
- Create an OIDC identity provider in AWS IAM that trusts GitHub's OIDC endpoint.
- Create an IAM role with the permissions Terraform needs, and configure its trust policy to allow the GitHub Actions OIDC provider to assume it.
- Update the workflow to use the
aws-actions/configure-aws-credentialsaction with OIDC.
Here's what the workflow step looks like:
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-terraform
aws-region: eu-west-1And the job needs an additional permission:
permissions:
contents: read
pull-requests: write
id-token: writeThe id-token: write permission allows the workflow to request an OIDC token from GitHub, which AWS then validates and exchanges for temporary credentials. No secrets to store, no keys to rotate.
Conclusion
Automating Terraform with GitHub Actions turns infrastructure changes into a reviewable, auditable process. The plan-on-PR and apply-on-merge pattern ensures that nothing reaches production without being reviewed first, and that every change is applied consistently through the same pipeline.
Here's a summary of the two workflows:
| Workflow | Trigger | What it does | When to use |
|---|---|---|---|
| Terraform Plan | Pull request to main |
Validates, plans, and posts output to PR | Every infrastructure change |
| Terraform Apply | Push to main (merge) |
Initializes and applies automatically | After PR is reviewed and merged |
A few things to keep in mind going forward:
- Always use a remote backend for state. Local state and CI/CD don't mix.
- Consider OIDC federation over static keys for better security.
- Add
terraform fmt -checkandterraform validateto catch issues early. - Use
continue-on-erroron the plan step so the PR comment always gets posted, even when the plan fails. - Protect the
mainbranch with required status checks so no one can merge without a passing plan.
Comments