Thumbnail

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 plan on every pull request so changes are reviewed before they're applied
  • Automate terraform apply on merge to main so 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 role
  • AWS_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_TOKEN as 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 1

Here's what each step does and why it matters:

  • actions/checkout@v4 pulls the repo code into the runner so Terraform has access to the configuration files.
  • hashicorp/setup-terraform@v3 installs Terraform on the runner. It also wraps the Terraform binary to capture stdout and stderr as step outputs, which is how the plan output gets passed to the PR comment step.
  • terraform fmt -check verifies that all .tf files follow the canonical format. If someone forgot to run terraform fmt locally, this catches it. The -check flag makes it exit with a non-zero code without modifying files.
  • terraform init initializes the backend and downloads provider plugins. The AWS credentials are needed here because the backend (S3, for example) requires authentication.
  • terraform validate checks 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-color generates the execution plan. The -no-color flag strips ANSI escape codes so the output renders cleanly in the PR comment. continue-on-error: true ensures the workflow doesn't stop here, so the PR comment step still runs even if the plan fails.
  • The actions/github-script step 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. The GITHUB_TOKEN is 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 permissions block at the job level is important. contents: read lets the workflow check out the code, and pull-requests: write grants 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 plan step 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:

  1. I create a branch and make changes to the Terraform configuration.
  2. I open a pull request against main.
  3. The Plan workflow runs automatically. It validates the config, generates a plan, and posts the output as a PR comment.
  4. I (or a teammate) review the plan in the PR. If something looks off, I push a fix and the workflow runs again.
  5. Once the plan looks good, I merge the PR.
  6. The Apply workflow triggers on the push to main and 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:

  1. Create an OIDC identity provider in AWS IAM that trusts GitHub's OIDC endpoint.
  2. Create an IAM role with the permissions Terraform needs, and configure its trust policy to allow the GitHub Actions OIDC provider to assume it.
  3. Update the workflow to use the aws-actions/configure-aws-credentials action 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-1

And the job needs an additional permission:

    permissions:
      contents: read
      pull-requests: write
      id-token: write

The 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 -check and terraform validate to catch issues early.
  • Use continue-on-error on the plan step so the PR comment always gets posted, even when the plan fails.
  • Protect the main branch with required status checks so no one can merge without a passing plan.

Comments