Terraform CI/CD with GitHub Actions
Build a Terraform GitHub Actions workflow that validates pull requests, creates a reviewed plan, authenticates with AWS using OIDC, and applies after approval.

On this page
Running Terraform by hand makes it easy for a production change to skip formatting, validation, review, or a second pair of eyes. A pull request workflow can run those checks consistently and show the proposed infrastructure changes before anyone approves a deployment.
This tutorial creates a GitHub Actions pipeline for an AWS Terraform root module. Pull requests run formatting, initialization, validation, and a plan. A push to the protected main branch creates a saved plan, then waits for approval in a protected GitHub environment before applying that exact plan.
Prepare the repository and backend
Store the Terraform root module in a dedicated directory such as infra/network. Commit .terraform.lock.hcl so provider selections are repeatable, and use a remote backend with state locking. For an AWS S3 backend, a recent Terraform release supports native lock files:
terraform {
backend "s3" {
bucket = "company-terraform-state"
key = "network/production.tfstate"
region = "us-east-1"
use_lockfile = true
}
}
Create separate state keys for independent environments. Do not commit credentials, terraform.tfstate, or a saved plan. State and plan files can contain values that should stay private.
Configure AWS identity federation
In AWS IAM, add GitHub’s OIDC provider with issuer https://token.actions.githubusercontent.com and audience sts.amazonaws.com. Create a read role for plans and a write role for deployment. Limit each role’s permissions to its state object, lock file, and required infrastructure actions.
Restrict the read role trust to the exact subjects needed by its jobs: a pull request subject such as repo:OWNER/REPOSITORY:pull_request and a main branch subject such as repo:OWNER/REPOSITORY:ref:refs/heads/main. Restrict the deployment role to the protected environment subject, such as repo:OWNER/REPOSITORY:environment:production. Replace the placeholders with your exact repository identity and do not allow a broad organization wildcard. Current GitHub repositories created after July 15, 2026, or repositories that opted in to immutable subjects, may include owner and repository IDs in sub; use the exact format GitHub issues for your repository.
In GitHub repository settings, add a production environment and require a reviewer. Protect main with required pull request checks. Forked pull requests will skip cloud planning in the workflow below because they should not receive credentials for private state or provider APIs.
Write narrow AWS role trust policies
An OIDC trust policy decides which workflow may assume a role. For repositories using GitHub’s standard subject format, the condition for a plan role can allow the pull request and protected branch subjects while requiring the expected audience:
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": [
"repo:OWNER/REPOSITORY:pull_request",
"repo:OWNER/REPOSITORY:ref:refs/heads/main"
]
}
}
Put this condition in the role’s AssumeRoleWithWebIdentity trust document, and replace the owner and repository placeholders. The apply role should allow only the production environment subject. Create the GitHub environment before relying on that subject, and require reviewers there. If your repository uses immutable subject claims, substitute its exact ID based subject values rather than copying the standard examples.
The trust policy controls who can assume the role. The attached permission policy controls what that role can do. For a plan role, permit reads for the resources in the configuration and reads plus lock operations on its exact state key. For the apply role, add only the resource changes needed by that root module. Avoid attaching broad administrator policies just to make an initial plan succeed.
Add the pull request and deployment workflow
Create .github/workflows/terraform.yml. Set the working directory and role ARNs to match your repository. Pin action versions to reviewed releases, or pin each action to a verified full commit SHA under your own dependency policy.
name: Terraform
on:
pull_request:
paths: ["infra/network/**", ".github/workflows/terraform.yml"]
push:
branches: [main]
paths: ["infra/network/**", ".github/workflows/terraform.yml"]
permissions:
contents: read
concurrency:
group: terraform-network-production
cancel-in-progress: false
jobs:
plan:
if: >-
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
defaults:
run:
working-directory: infra/network
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v4
with:
terraform_version: 1.13.3
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_TERRAFORM_PLAN_ROLE_ARN }}
aws-region: us-east-1
- run: terraform fmt -check -recursive
- run: terraform init -input=false
- run: terraform validate
- run: terraform plan -input=false -no-color -out=tfplan
- if: github.event_name == 'push'
uses: actions/upload-artifact@v4
with:
name: network-production-plan
path: infra/network/tfplan
retention-days: 1
apply:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: plan
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
id-token: write
defaults:
run:
working-directory: infra/network
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v4
with:
terraform_version: 1.13.3
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_TERRAFORM_APPLY_ROLE_ARN }}
aws-region: us-east-1
- run: terraform init -input=false
- uses: actions/download-artifact@v4
with:
name: network-production-plan
path: infra/network
- run: terraform apply -input=false tfplan
Check the current Terraform CLI release and update the example pin after testing upgrades. In a real workflow, ensure the AWS roles trust the correct event and environment subjects, and that the plan role cannot make infrastructure changes.
The workflow assumes a single root module and one AWS region. For a repository with several root modules, create one workflow per state boundary or add a reviewed matrix that maps each directory to its own backend key and role. Do not dynamically build a working directory or state key from an untrusted pull request title. Every matrix entry should be explicit so a change for development cannot accidentally plan against production state.
Verify the pull request path
Open a branch with a small Terraform change and create a pull request from the same repository. Confirm that the workflow runs fmt, init, validate, and plan in order. The job log should show the proposed resource actions and fail if formatting or validation fails.
Protect the required status check in the branch rules. Treat plan output as sensitive because providers can reveal values even when Terraform marks some output as sensitive. Avoid printing secrets in workflow commands or posting unredacted plans to public pull requests.
If the workflow fails during initialization, check that the backend bucket, state key, region, and lock configuration match the role policy. If terraform plan reports an access denied error, distinguish state access from provider reads before adding permissions. A read role can need s3:GetObject for state plus narrowly scoped write and delete permissions for the native lock file. The exact AWS permissions depend on the backend and resources you declare, so inspect CloudTrail and the provider error rather than adding a wildcard.
If plan output is too large for a useful review, publish a concise summary of create, update, replace, and delete counts while retaining the full plan in a restricted artifact. A saved binary plan can include sensitive values, so artifact access and retention matter even if the text output masks those values. Do not upload the artifact for a forked pull request.
Verify the protected apply path
Merge an approved change into main. The plan job should create a binary plan and upload it as a short lived artifact. The apply job should pause at the production environment until its reviewer approves the run.
After approval, the job downloads tfplan and runs terraform apply tfplan. Applying a saved plan uses the reviewed actions instead of silently generating a different plan. If state changed after the plan was saved, Terraform can reject it as stale; rerun the workflow to create a new plan rather than bypassing the error.
The artifact must come from the same workflow run and commit as the apply job. Keep the artifact name unique to the environment and avoid reusing a plan from an earlier run. A saved plan is tied to the state snapshot and provider configuration used to create it. Terraform intentionally refuses to apply it if the remote state changed, because another deployment may have modified the same resources in the meantime.
The protected environment approval is a deployment gate, not a substitute for reviewing the pull request. Review source changes and the plan before merge, then have the environment reviewer inspect the run and artifact metadata before approving apply. If your team needs a distinct approval after seeing the exact post merge plan, configure the deployment environment so that the reviewer receives the plan summary and can access the restricted artifact before approval.
Harden the workflow before production
Give the job only the permissions it needs. The workflow grants id-token: write only inside Terraform jobs, while repository content remains read only. Keep a read role and an apply role separate, protect the deployment environment, and restrict backend access to the single state key.
For public repositories, do not expose a cloud plan role to code from forks. A Terraform plan executes provider code and reads remote state. If contributors are not trusted with those capabilities, run credential free validation for forked pull requests and require a trusted maintainer to trigger the cloud plan after reviewing the code.
Keep the plan and apply jobs serialized for a given state so two workflow runs do not try to update the same resources at once. Terraform’s backend lock remains the final guard for state operations, but GitHub concurrency makes the queue easier to understand and reduces avoidable lock contention. Use a different concurrency group for each independent state rather than blocking unrelated environments behind one global job.
Review the workflow after changes to Terraform, backend configuration, action versions, role trust, and repository protections. A trusted workflow can still be weakened by a pull request that edits the workflow itself, so require an owner review for changes under .github/workflows and for IAM trust policy updates. Keep the action versions pinned and update them through a normal reviewed change.
The official GitHub Actions Terraform setup documents the CLI action, and GitHub’s OIDC reference describes repository and environment claims used in cloud trust policies.
Start with a nonproduction state key and test both paths: an ordinary pull request and an approved merge to main. Once the status checks, OIDC trust, artifact access, and environment gate behave as intended, apply the same pattern to production with a narrowly scoped IAM policy.
Frequently Asked Questions
How do I run Terraform plan in GitHub Actions?
Should GitHub Actions run terraform apply on every pull request?
Can GitHub Actions authenticate to AWS without access keys?
Stop reaching for a spreadsheet
Obelinf keeps every subnet, device, circuit, and rack in one live source of truth, with audit logs and a topology view. Free for personal use.
Related Articles

Terraform AWS VPC IPAM Tutorial
Create an AWS VPC IPAM pool with Terraform, provision address space, and allocate a VPC CIDR from the pool instead of hard coding each VPC range.
Read more
Terraform CIDR Planning with Subnet Examples
Use Terraform cidrsubnet and cidrsubnets to divide a VPC range into stable subnet blocks, validate the result, and avoid renumbering deployed networks.
Read more
Terraform GKE Networking with Secondary Ranges
Build a Google Cloud VPC and GKE Autopilot cluster with Terraform, reserve primary and secondary subnet ranges, and connect Pods and Services to named ranges.
Read more