Terraform Operations #1 Team Workflow: plan Review on PRs, apply via GitHub Actions

4 min read

Ten parts of the practice series built the infrastructure, but the way we operate it is still “everyone runs apply from their own laptop.” That approach does not survive long on a team. Strong AWS credentials have to live on every individual laptop, changes get applied without review, and there is no record of who applied what, or when. The first topic of this seven-part operations series is changing that structure. The goal fits in one sentence: humans review the code and the plan; only CI runs apply.

The shape of the workflow: plan on PRs, apply on merge #

Since the PR is the gateway for Git collaboration, we align Terraform’s gateway with it.

Workflow overview
Edit code on a branch → open a PR
  → CI: fmt check, validate, run plan
  → plan output posted as a PR comment
  → reviewer approves the code and the plan together
Merge to main
  → CI: apply the merged code

The key shift in perspective is that the review target is not just the code but the plan. The code diff shows intent; the plan shows outcome. In Basics #2 we said you must always check the -/+ (replace) symbol — this is the moment that check stops being a personal habit and becomes the team’s review procedure.

Credentials: collecting on the OIDC role #

CI needs credentials to touch AWS, and the GitHub OIDC role we built in Practice #7 was made for exactly this. Attach deployment permissions to that role (access to the managed resources and the state bucket), and the workflow assumes the role without any access keys. With no stored secrets, there are no secrets to leak.

The GitHub Actions workflows #

We use two files. First, the PR side.

.github/workflows/terraform-plan.yml
# .github/workflows/terraform-plan.yml
name: terraform plan
on:
  pull_request:
    paths: ["envs/**", "modules/**"]

permissions:
  id-token: write      # issue the OIDC token
  contents: read
  pull-requests: write # post the plan comment

jobs:
  plan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ vars.TF_PLAN_ROLE_ARN }}
          aws-region: ap-northeast-2
      - uses: hashicorp/setup-terraform@v3
      - run: terraform fmt -check -recursive
      - run: terraform -chdir=envs/dev init
      - run: terraform -chdir=envs/dev validate
      - run: terraform -chdir=envs/dev plan -no-color -out=tfplan
      # followed by a step that posts the plan output as a PR comment

Posting the plan output as a PR comment is handled by a public action or a short script using gh pr comment. Next, the merge side.

.github/workflows/terraform-apply.yml
# .github/workflows/terraform-apply.yml
name: terraform apply
on:
  push:
    branches: [main]
    paths: ["envs/**", "modules/**"]

permissions:
  id-token: write
  contents: read

concurrency:
  group: terraform-dev
  cancel-in-progress: false

jobs:
  apply:
    runs-on: ubuntu-latest
    environment: dev
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ vars.TF_APPLY_ROLE_ARN }}
          aws-region: ap-northeast-2
      - uses: hashicorp/setup-terraform@v3
      - run: terraform -chdir=envs/dev init
      - run: terraform -chdir=envs/dev apply -auto-approve

Every one of these settings is an operational safeguard.

  • concurrency: even when merges land back to back, only one apply runs at a time. If the state locking from Basics #9 is the last line of defense, this is the first — and cancel-in-progress: false ensures an in-flight apply is never interrupted (interrupting an apply leaves resources in a half-finished state).
  • environment: dev: configure required reviewers on the GitHub environment and you get a human approval step right before apply. Especially useful in the prod workflow.
  • paths filter: the workflow only runs when infrastructure code changes, so commits like documentation edits do not waste CI.
  • role separation: give the plan role read-mostly permissions and reserve write permissions for the apply role, and the PR stage runs with minimal privileges.

How the trust boundary changes #

Once this workflow settles in, the permission structure changes. People keep only read and plan permissions; write permissions live only in CI’s role. If an emergency requires a local apply, it is still possible — but it is an exception procedure, and the very fact that an exception occurred lands in the audit trail. “Who applied what, and when” is now fully answered by Git history and the Actions logs.

Extending to prod keeps the same structure with different values. Add a workflow for envs/prod with the prod role and an approval-required environment, and the “dev first, verify, then prod” flow we built in Practice #9 runs on top of CI.

Recap #

What we covered in this post:

  • Local apply carries three problems — concentrated credentials, no review, no record. The principle is “humans review, CI applies”
  • PRs run fmt, validate, and plan, with the plan output left as a comment, so code and outcome are reviewed together
  • CI credentials come from the OIDC role of Practice #7. Splitting the plan role from the apply role minimizes PR-stage permissions
  • concurrency prevents simultaneous applies, and environment approvals put a human gate in front of prod
  • Exceptional local applies remain possible, but they become exceptions that leave a record

In the next post (#2 Drift and State Surgery), we deal with changes that happen outside this workflow: detecting manual console changes, and bringing resources into or out of state with the import and removed blocks.

X