Skip to content
Infrastructure as Code iac cicd 5 min read

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.

StageTriggerWhat it doesPermissions needed
fmt / validatepull_requestStyle and syntax gatesRead only
planpull_requestCompute diff, comment on PRRead state, write PR comments
applypush to mainApply approved changesRead/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 sub condition as far as you can. Restricting to repo:devcraftly/infra:ref:refs/heads/main means only workflows running on the main branch 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 echo a secret or pass it on a command line where it could be logged. GitHub masks registered secrets in logs, but TF_VAR_ values and provider tokens belong in env, not in run arguments.

Best Practices

  • Authenticate with OIDC and assume an IAM role; never store static cloud access keys as secrets.
  • Run fmt -check, validate, and plan on every pull request, and post the plan as a comment for review.
  • Apply only on push to main so unmerged code can never touch real infrastructure.
  • Pin terraform_version and action versions (@v4, not @main) for reproducible, supply-chain-safe runs.
  • Set permissions to the minimum each job needs — id-token: write for OIDC, pull-requests: write only on the plan job.
  • Gate the apply job behind a GitHub environment to add required reviewers and deployment protection rules.
  • Use a remote backend with state locking so concurrent workflow runs cannot corrupt state.
Last updated June 14, 2026
Was this helpful?