This documents how our Terraform CI/CD pipeline is set up in GitHub Actions: specifically the auth model, the state backend, the two-file workflow structure, and how I close the loop on post-merge apply results showing up on the PR that triggered them.

The Two-File Workflow Structure

The pipeline is split into two files: a thin caller and a reusable template.

terraform.yml — the caller. It defines the triggers and hands off to the template:

name: "Terraform CI/CD"
on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main
    types: [opened, synchronize, reopened]
permissions:
  id-token: write
  contents: read
  pull-requests: write
jobs:
  terraform:
    uses: ./.github/workflows/terraform-template.yml
    secrets: inherit

That’s it. The caller owns the triggers and the top-level permissions block; everything else lives in the template. The secrets: inherit passes all repository secrets through to the reusable workflow without having to enumerate them.

The reason for splitting it is reusability — if another repo in the org needs the same pipeline, it can call terraform-template.yml directly via workflow_call without copying the implementation. I don’t have an immediate need for this but thought I’d future-proof a bit.

terraform-template.yml — the actual implementation. It accepts the call and runs all the steps.

Azure Authentication: OIDC Instead of Stored Credentials

The most important piece of the auth model is that there are no Azure credentials stored as GitHub secrets. We use OIDC (OpenID Connect) federation instead.

When a GitHub Actions job runs, GitHub’s token service issues a short-lived JWT for that specific workflow run. Azure is configured to trust JWTs from GitHub’s issuer for a specific repository, branch, or environment. The job exchanges that JWT for an Azure access token at runtime. No client secret or certificate ever touches GitHub.

The permission that makes this work is id-token: write in the permissions block — that’s what allows the runner to request the JWT from GitHub’s token service. Without it, the OIDC exchange can’t happen.

In the template, the Azure login step looks like this:

- name: Azure Login (OIDC)
  uses: azure/login@v1
  with:
    client-id: ${{ vars.AZURE_CLIENT_ID }}
    tenant-id: ${{ vars.AZURE_TENANT_ID }}
    subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}

Notice these come from vars (GitHub Actions variables, not secrets) — the client ID, tenant ID, and subscription ID aren’t sensitive. The sensitive part is the trust relationship configured in Azure Entra: the app registration is federated to accept tokens from this specific GitHub repo. No secret to rotate, no credential to leak.

The Terraform azurerm provider picks this up automatically through environment variables set at the job level:

env:
  ARM_USE_OIDC: true
  ARM_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }}
  ARM_TENANT_ID: ${{ vars.AZURE_TENANT_ID }}
  ARM_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }}

ARM_USE_OIDC: true tells the provider to use the OIDC token from the environment rather than a client secret. The Azure login step earlier puts the token where the provider expects to find it.

Remote State in Azure Blob Storage

Terraform state lives in an Azure Storage Account. The backend config is generated at runtime rather than checked in, because the storage account name and resource group are environment-specific and we don’t want them hardcoded in the repo:

- name: Create Backend Config
  run: |
    cat > backend.tf << EOF
    terraform {
      backend "azurerm" {
        resource_group_name  = "${{ vars.TERRAFORM_STATE_RG }}"
        storage_account_name = "${{ vars.TERRAFORM_STATE_STORAGE }}"
        container_name      = "tfstate"
        key                 = "${{ github.event.repository.name }}/${{ github.event.repository.name }}.tfstate"
      }
    }
    EOF    

The state file key is scoped to the repository name, so multiple repos can share the same storage account without colliding. The storage account key (TERRAFORM_STORAGE_KEY) is the one actual secret here, it authenticates Terraform’s access to the blob container for reading and writing state.

What Runs on a PR vs. What Runs on Merge

The same template handles both triggers, but different steps run depending on the event.

On a pull request: fmt, init, validate, plan — and then a comment is posted to the PR with all four outcomes:

#### Terraform Format and Style 🖌 `success`
#### Terraform Initialization ⚙️ `success`
#### Terraform Validation 🤖 `success`
#### Terraform Plan 📖 `success`

<details><summary>Show Plan</summary>
...
</details>

The plan is saved to tfplan during this step. That artifact is what the apply step consumes — this guarantees that what gets applied is exactly what was reviewed, not a fresh plan that could differ if something changed in the window between merge and apply.

On a push to main (i.e., after a PR is merged): the same fmt, init, validate, and plan steps run again — then apply runs against the saved plan.

The conditional that gates apply:

if: github.ref == 'refs/heads/main' && github.event_name == 'push'

github.event_name == 'push' distinguishes a merge from a PR targeting main. A PR against main triggers pull_request, not push, so apply never fires on an unmerged PR.

The Post-Merge PR Feedback Problem

GitHub treats post-merge main branch runs as entirely separate workflow runs with no automatic link back to the PR that was merged. GitLab does this by default — a pipeline triggered by a merge is linked to the MR. GitHub doesn’t.

The result: the person who merges can see the pre-merge plan comment on their PR, but has no visibility into whether the apply that runs afterward succeeded or failed without navigating to the Actions tab separately.

The Fix

GitHub’s API does maintain an association between commits and PRs — it just doesn’t surface it automatically. After apply, a github-script step queries listPullRequestsAssociatedWithCommit using the merge commit SHA and posts a comment to whatever PR it finds:

- name: Terraform Apply
  id: apply
  if: github.ref == 'refs/heads/main' && github.event_name == 'push'
  run: terraform apply -auto-approve tfplan 2>&1 | tee apply_output.txt
  continue-on-error: true

- name: Post Apply Result to PR
  uses: actions/github-script@v6
  if: github.ref == 'refs/heads/main' && github.event_name == 'push'
  with:
    github-token: ${{ secrets.GITHUB_TOKEN }}
    script: |
      const fs = require('fs');
      const path = require('path');
      const applyFile = path.join(process.env.GITHUB_WORKSPACE, '${{ inputs.working_directory }}', 'apply_output.txt');
      let applyOutput = '';
      try {
        applyOutput = fs.readFileSync(applyFile, 'utf8');
      } catch (e) {
        applyOutput = 'No output captured';
      }

      const outcome = '${{ steps.apply.outcome }}';
      const emoji = outcome === 'success' ? '✅' : '❌';

      const body = `#### Terraform Apply ${emoji} \`${outcome}\`

      <details><summary>Show Apply Output</summary>

      \`\`\`
      ${applyOutput}
      \`\`\`

      </details>

      *Triggered by: ${{ github.sha }} — [View run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})*`;

      const prs = await github.rest.repos.listPullRequestsAssociatedWithCommit({
        owner: context.repo.owner,
        repo: context.repo.repo,
        commit_sha: context.sha
      });

      if (prs.data.length > 0) {
        await github.rest.issues.createComment({
          issue_number: prs.data[0].number,
          owner: context.repo.owner,
          repo: context.repo.repo,
          body: body
        });
      }      

- name: Fail if Apply Failed
  if: github.ref == 'refs/heads/main' && github.event_name == 'push' && steps.apply.outcome == 'failure'
  run: exit 1

Three things worth calling out:

continue-on-error: true on apply — if apply fails and the step hard-fails the job, the comment step never runs. continue-on-error: true lets the job continue so the failure gets reported back to the PR. The steps.apply.outcome is still failure, just the job doesn’t stop there.

Fail if Apply Failed at the end — because continue-on-error: true marks the job as succeeded even when the step fails, you need an explicit exit 1 after the comment step to ensure the workflow run itself is marked red. Without this, a broken apply would post a failure comment to the PR but the Actions run would show green.

pull-requests: write — this permission was already in the job block for the pre-merge plan comment step, so no new permissions were needed for the post-merge comment.

Putting It Together

The full lifecycle for a change:

  1. Open a PR → workflow triggers on pull_request, runs fmt/init/validate/plan, posts a comment with all four outcomes and a collapsible plan diff
  2. Merge the PR → workflow triggers on push to main, runs the same steps, applies the saved plan, posts the apply result (success or failure) back to the now-merged PR
  3. The person who merged can see both the plan and the apply result on the PR page without ever leaving it