Running terraform apply from a laptop works for one person. On a team it means nobody knows exactly which code was applied, credentials live on many machines, and a forgotten git pull can roll back a colleague's change. The fix is to make the pipeline the only thing that applies infrastructure.

This is part 5, the last part of Terraform from Zero. You will add tests to the module from part 4, then build a GitHub Actions workflow that checks every pull request, posts a plan, and applies only after a merge to main, using short-lived AWS credentials.

The workflow you are aiming for

  • On every pull request: format check, validate, run tests and produce a plan reviewers can read.
  • On merge to main: plan again and apply, behind an approval step for production.
  • No long-lived keys: GitHub proves its identity to AWS with OIDC and receives temporary credentials for each run.

Test the module with terraform test

Terraform has a built-in test framework. Test files end in .tftest.hcl and contain run blocks that plan or apply the configuration, followed by assertions.

Create modules/private-bucket/tests/bucket.tftest.hcl:

hcl
mock_provider "aws" {}

variables {
  name_prefix = "test-notes"
}

run "versioning_on_by_default" {
  command = plan

  assert {
    condition     = aws_s3_bucket_versioning.this.versioning_configuration[0].status == "Enabled"
    error_message = "Versioning should be enabled by default."
  }
}

run "public_access_is_blocked" {
  command = plan

  assert {
    condition     = aws_s3_bucket_public_access_block.this.block_public_policy
    error_message = "Public bucket policies must be blocked."
  }
}

run "versioning_can_be_suspended" {
  command = plan

  variables {
    versioning_enabled = false
  }

  assert {
    condition     = aws_s3_bucket_versioning.this.versioning_configuration[0].status == "Suspended"
    error_message = "versioning_enabled = false should suspend versioning."
  }
}

Run it from the module folder:

bash
terraform init
terraform test

command = plan checks the logic without creating anything. mock_provider (available since Terraform 1.7) replaces the real AWS provider with fake responses, so these tests need no AWS credentials and run in seconds. Use command = apply with a real provider in a separate, sandboxed account when you need integration tests that create resources and clean them up.

Let GitHub Actions assume an AWS role with OIDC

Instead of storing an access key as a GitHub secret, configure AWS to trust GitHub's OIDC identity provider and create an IAM role that only your repository can assume. The trust policy looks like this:

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:acme/infra:*"
        }
      }
    }
  ]
}

The sub condition restricts the role to one repository. Tighten it further for production, for example to repo:acme/infra:environment:production, so only jobs running in that GitHub environment can apply. A common pattern is two roles: a read-only plan role for pull requests and a write role for applies from main.

The configure-aws-credentials action documents the full setup, and you can create the OIDC provider and role with Terraform in your bootstrap configuration.

The GitHub Actions workflow

Create .github/workflows/terraform.yml:

yaml
name: terraform

on:
  pull_request:
    paths: ["infra/**"]
  push:
    branches: [main]
    paths: ["infra/**"]

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

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

jobs:
  plan:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: infra/envs/dev
    steps:
      - uses: actions/checkout@v5

      - uses: hashicorp/setup-terraform@v4
        with:
          terraform_version: "1.16.5"
          terraform_wrapper: false

      - uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: arn:aws:iam::111122223333:role/github-terraform-plan
          aws-region: eu-west-2

      - run: terraform fmt -check -recursive ../..
      - run: terraform init -input=false
      - run: terraform validate
      - run: terraform test
        working-directory: infra/modules/private-bucket
      - run: terraform plan -input=false -out=tfplan
      - run: terraform show -no-color tfplan > plan.txt

      - uses: actions/upload-artifact@v4
        with:
          name: tfplan
          path: infra/envs/dev/tfplan

  apply:
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    needs: plan
    runs-on: ubuntu-latest
    environment: dev
    defaults:
      run:
        working-directory: infra/envs/dev
    steps:
      - uses: actions/checkout@v5
      - uses: hashicorp/setup-terraform@v4
        with:
          terraform_version: "1.16.5"
          terraform_wrapper: false
      - uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: arn:aws:iam::111122223333:role/github-terraform-apply
          aws-region: eu-west-2
      - uses: actions/download-artifact@v5
        with:
          name: tfplan
          path: infra/envs/dev
      - run: terraform init -input=false
      - run: terraform apply -input=false tfplan

Check the latest major versions of each action when you copy this; they change over time.

Why each piece is there

  • `id-token: write` lets the job request an OIDC token. Without it the AWS action cannot assume the role.
  • `concurrency` queues runs for the same environment instead of running two at once. State locking from part 3 protects the state; this keeps the pipeline itself orderly.
  • Pinned `terraform_version` makes CI use the same Terraform as your lock file and your team.
  • `fmt -check` and `validate` fail fast on style and syntax problems before any AWS call.
  • `plan -out=tfplan` then `apply tfplan` applies exactly the plan that was reviewed. If the state changed in between, Terraform refuses the stale plan rather than applying something different.
  • `environment: dev` connects the apply job to a GitHub environment. For production, add required reviewers to that environment so a person approves every apply.

Saved plan files can contain sensitive values, so keep artifact retention short and limit who can download them.

Show the plan on the pull request

Reviewers should see the plan without opening the logs. Add a step after terraform show that posts plan.txt as a pull request comment, for example with actions/github-script or the GitHub CLI (gh pr comment). Keep the comment updated instead of adding a new one on every push, and truncate very long plans with a link to the full log.

Scale to more environments

Duplicate the jobs for each folder under infra/envs/, or use a matrix over environment names. Keep one state file, one IAM role and one GitHub environment per environment, and promote changes in order: dev, then staging, then production.

Key takeaways

  • Make CI the only path to apply; laptops plan, pipelines apply.
  • terraform test with command = plan and mock_provider gives fast unit tests with no cloud access.
  • Use GitHub OIDC with tightly scoped IAM trust policies instead of stored AWS keys.
  • Run fmt -check, validate, tests and plan on every pull request, and apply the saved plan after merge.
  • Use GitHub environments with required reviewers to gate production applies.

That completes Terraform from Zero. You started with one bucket created from a laptop and finished with tested, modular infrastructure delivered through a pipeline. Revisit any part from the series playlist, and keep your notebook handy for the commands you will use most.