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:
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:
terraform init
terraform testcommand = 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:
{
"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:
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 tfplanCheck 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 testwithcommand = planandmock_providergives 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 andplanon 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.

Ansible for Beginners: Automate an Ubuntu Server with Your First Playbook4 min read
Terraform and Ansible Together: Provision with One, Configure with the Other4 min read
Vercel Fluid Compute Explained: Concurrency, Active CPU Pricing and waitUntil4 min read