RSS

Building a CI/CD pipeline for Terraform with GitHub Actions

Terraform GitHub CI/CD Infrastructure Automation

What you'll find in this guide

This step-by-step guide explores Terraform automation using GitHub Actions. You'll set up secure OIDC authentication for both AWS and Google Cloud, configure remote state storage, and build a complete workflow that runs terraform plan on pull requests and applies changes automatically on merge. By the end, you'll have a production-ready pipeline that eliminates manual errors and speeds up infrastructure deployments.

Introduction to CI/CD pipelines for Terraform

Manual Terraform deployments kill productivity. Every terraform apply from your laptop risks configuration drift, credential exposure, and human error. These inefficiencies cause teams to spend more time fixing broken deployments than building infrastructure.

A proper CI/CD pipeline eliminates this chaos. Open a pull request (or a merge request, if you're on GitLab), and GitHub Actions automatically runs terraform plan and posts results as comments. Merge the PR, and changes apply automatically to your cloud account. No more "works on my machine" problems.

This guide shows you how to build such a CI/CD pipeline for Terraform. You'll configure secure OIDC authentication, set up a remote backend, and create a complete workflow that handles everything from formatting to deployment, whether your cloud provider is AWS, Google Cloud, or both.

Prerequisites: We assume that you have some Terraform code you want to deploy; if not, refer to Organizing Terraform Code for Scalability and Maintainability for ideas.

Where your Terraform code lives

Before any of the pipeline mechanics, you need to ask yourself one structural question: does your Terraform code live in the same repository as your application code, in a separate repository, or split across multiple repositories?

Keeping infrastructure code and app code in the same repository works well for a single team that owns both. A pull request that adds an environment variable to the application and the Terraform resource that provisions it stays reviewable as one change, and there's no coordination tax between repos.

You will start encountering costs once a platform team starts managing infrastructure for several application teams: everyone's Terraform changes now compete for review attention on a repository most of them don't otherwise touch.

Solve that by having a separate repository (or several, split by service or by cloud account), as it gives infrastructure its own lifecycle, its own branch protection rules, and its own pull request history, independent from the deploy cadence of any one application.

The trade is coordination: a change that spans the app and its infrastructure now needs two pull requests, sometimes two approvals, and a plan for which one merges first.

There's no universally right answer here, and the pattern you inherited is usually fine to keep; what is important to this guide is that everything downstream (the paths filter that triggers your workflow, the directory your CI job runs terraform init in) assumes you've made this choice deliberately rather than by accident.

A feature branch that touches the root module and the application's source code in the same commit is the clearest signal you've made the right call for a coupled repo. If that pull request keeps needing to be split in two before review, that's the signal you haven't.

Pinning a reproducible execution environment

Terraform run from your laptop (your local machine, in other words) has an implicit environment: a Terraform binary somewhere on your PATH, providers already downloaded into .terraform, maybe a wrapper script you forgot you wrote.

None of that exists on a CI runner, which starts clean on every run. hashicorp/setup-terraform (used throughout this guide) solves the binary question by installing a pinned version before anything else runs:

- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.11.0"

Pin the version explicitly. An unpinned setup-terraform step takes whatever is latest at runtime, which means a terraform fmt rule or provider behavior can change under you between one week and the next with no apparent code change happening.

If you run OpenTofu rather than Terraform, opentofu/setup-opentofu is the equivalent action and takes the same shape; everything else in this guide (the init, plan, and apply steps) is identical either way, since the CLI surface the two tools expose hasn't diverged.

Configuring OpenID Connect (OIDC) for GitHub

Security Alert: Long-lived AWS credentials in your GitHub repository are a security nightmare waiting to happen. OIDC eliminates this risk by letting GitHub assume AWS roles temporarily without storing permanent keys.

The setup requires two things: an AWS IAM role that trusts GitHub's OIDC provider, and a GitHub workflow that assumes that role.

Create the AWS IAM role

First, create an identity provider in AWS IAM for GitHub's OIDC endpoint, for example:

aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com \
--thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1 1c58a3a8518e8759bf075b76b750d4f2df264fcd

Next, create a trust policy that allows GitHub to assume your role. Replace YOUR_ORG and YOUR_REPO with your actual values:

{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::YOUR_ACCOUNT_ID: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:YOUR_ORG/YOUR_REPO:*"
}
}
}
]
}

Create the IAM role using this trust policy:

aws iam create-role \
--role-name GitHubActionsTerraformRole \
--assume-role-policy-document file://trust-policy.json

Attach the necessary permissions. For Terraform, you typically need broad access, but scope it down for production:

aws iam attach-role-policy \
--role-name GitHubActionsTerraformRole \
--policy-arn arn:aws:iam::aws:policy/PowerUserAccess

Scoping this policy down, rather than attaching PowerUserAccess and moving on, gives you greater control over what the role can assume and touch, and shrinks the blast radius of a compromised workflow run to whatever that narrower policy allows.

Configure GitHub workflow authentication

In your GitHub Actions workflow, configure the OIDC authentication:

name: Terraform CI/CD
on:
pull_request:
paths: ['**.tf', '**.tfvars']
push:
branches: [main]
permissions:
id-token: write
contents: read
pull-requests: write
jobs:
terraform:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::YOUR_ACCOUNT_ID:role/GitHubActionsTerraformRole
aws-region: us-east-1

Important: The permissions block is critical. Without id-token: write, GitHub won't generate the OIDC token needed for authentication.

Troubleshooting Tip: If you see "AssumeRoleWithWebIdentity failed", check that your trust policy's sub condition matches your repository format exactly. It should be repo:organization/repository:* for any branch, or repo:organization/repository:ref:refs/heads/main for specific branches only.

Authenticating to Google Cloud with workload identity federation

The same thing applies to Google Cloud, through workload identity federation rather than IAM roles. GitHub's OIDC token gets exchanged for a short-lived Google credential that impersonates a service account, so no JSON key file ever sits in a repository secret.

Create the Google Cloud resources

Create a workload identity pool, then an OIDC provider inside it scoped to your GitHub organization:

gcloud iam workload-identity-pools create "github-pool" \
--project="YOUR_PROJECT_ID" \
--location="global" \
--display-name="GitHub Actions pool"
gcloud iam workload-identity-pools providers create-oidc "github-provider" \
--project="YOUR_PROJECT_ID" \
--location="global" \
--workload-identity-pool="github-pool" \
--display-name="GitHub provider" \
--issuer-uri="https://token.actions.githubusercontent.com" \
--attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository" \
--attribute-condition="assertion.repository_owner == 'YOUR_ORG'"

Create the service account your CI runs will impersonate, and grant it the roles/iam.workloadIdentityUser binding scoped to your repository:

gcloud iam service-accounts create terraform-ci \
--project="YOUR_PROJECT_ID" \
--display-name="Terraform CI service account"
gcloud iam service-accounts add-iam-policy-binding \
"terraform-ci@YOUR_PROJECT_ID.iam.gserviceaccount.com" \
--project="YOUR_PROJECT_ID" \
--role="roles/iam.workloadIdentityUser" \
--member="principalSet://iam.googleapis.com/projects/YOUR_PROJECT_NUMBER/locations/global/workloadIdentityPools/github-pool/attribute.repository/YOUR_ORG/YOUR_REPO"

Run the following command to grant that service account whatever role your Terraform code actually needs. A firewall rule and a Cloud Storage bucket don't need the same permissions a GKE cluster does, so resist the temptation to hand it roles/owner; scope it per environment the same way you'd scope the AWS policy above. Production environments in particular warrant a custom role rather than a broad predefined one.

Configure the GitHub workflow

- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v2
with:
workload_identity_provider: projects/YOUR_PROJECT_NUMBER/locations/global/workloadIdentityPools/github-pool/providers/github-provider
service_account: terraform-ci@YOUR_PROJECT_ID.iam.gserviceaccount.com

This step needs the same id-token: write permission declared earlier. Once it runs, terraform plan and terraform apply authenticate against Google Cloud exactly the way they would from a workstation with gcloud auth application-default login already run, just without a human or a long-lived key involved.

Secrets that aren't credentials: OIDC removes long-lived cloud keys from your repository, but your Terraform code may still reference other sensitive data, a database password, an API token for a third-party provider. Pull those from your cloud's secret manager (AWS Secrets Manager or Google Secret Manager) at apply time rather than storing them as GitHub Actions secrets in plaintext; a secret manager adds every read to audit logs, and a leaked GitHub Actions secret doesn't rotate itself.

Storing Terraform state remotely

Problem: Local state files don't do well when working in a team. When multiple developers run Terraform from their laptops, state conflicts are inevitable. Someone overwrites changes, resources drift, or worse, the state file gets corrupted, and you lose track of your infrastructure entirely.

Solution: A remote backend solves this by centralizing state in a shared location with proper locking. For AWS environments, S3 can provide both the storage and the lock itself: as of Terraform 1.11, the S3 backend's native use_lockfile option locks state with a lockfile object in the same bucket, no separate database required. The older pattern of pairing S3 with a DynamoDB table for locking still works, but HashiCorp now documents it as deprecated and scheduled for removal in a future minor version, so new pipelines should start with native locking.

Create the S3 backend infrastructure

You need an S3 bucket for state storage. Native S3 locking removes the need for a separate DynamoDB table, so that bucket is the only piece of locking infrastructure to create:

# backend-setup.tf
resource "aws_s3_bucket" "terraform_state" {
bucket = "your-company-terraform-state-${random_id.bucket_suffix.hex}"
}
resource "random_id" "bucket_suffix" {
byte_length = 4
}
resource "aws_s3_bucket_versioning" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_public_access_block" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}

Note: Apply this configuration manually first: terraform apply. Note the bucket name from the output; you'll need it for the backend configuration.

Configure the remote backend

Once your backend infrastructure exists, configure your main Terraform code to use it:

# backend.tf
terraform {
backend "s3" {
bucket = "your-company-terraform-state-abc123"
key = "infrastructure/terraform.tfstate"
region = "us-east-1"
use_lockfile = true
encrypt = true
}
}

For multiple environments, organize state files by path:

# Production
key = "production/infrastructure/terraform.tfstate"
# Staging
key = "staging/infrastructure/terraform.tfstate"
# Development
key = "development/infrastructure/terraform.tfstate"

Remote state on Google Cloud Storage

The GCS backend needs no separate lock table either. Cloud Storage provides native object locking, the same way S3's own use_lockfile option does now, so neither cloud needs a second piece of locking infrastructure provisioned alongside the bucket:

# backend.tf
terraform {
backend "gcs" {
bucket = "your-company-terraform-state"
prefix = "infrastructure/state"
}
}

The same environment-promotion structure applies: swap the prefix per environment (production/infrastructure/state, staging/infrastructure/state) the way the S3 example swaps the key. The bucket itself needs versioning enabled and public access blocked, the same two properties the S3 setup above configures explicitly.

Environment-specific configuration

Use environment variables in your GitHub Actions workflow to handle different environments:

- name: Setup Terraform Backend
run: |
cat > backend.tf << EOF
terraform {
backend "s3" {
bucket = "${{ vars.TERRAFORM_STATE_BUCKET }}"
key = "${{ github.ref_name }}/terraform.tfstate"
region = "${{ vars.AWS_REGION }}"
use_lockfile = true
encrypt = true
}
}
EOF

Set TERRAFORM_STATE_BUCKET and AWS_REGION as repository variables in GitHub. This approach is flexible and keeps your environment configuration out of the Terraform code itself, checked into version control the same way as everything else in the repository.

This is one of three common ways to separate environments, alongside Terraform workspaces (same code, several named instances of state) and environment branches, where a long-lived staging or production branch maps to its own backend key by convention.

Folder-per-environment tends to read the most clearly in a pull request diff and keeps separate environment configurations easy to compare side by side; workspaces reduce duplication at the cost of a command (terraform workspace select) every engineer has to remember to run.

Best Practice: Never commit backend configuration with hardcoded bucket names to version control if you're using the same repository across multiple AWS accounts. Use variables or separate backend files per environment.

Adding PR comments and status badges

Terraform plans buried in CI logs are useless. Your team needs to see the plan output directly in pull requests to make informed decisions about infrastructure changes. Status badges and PR comments bring this visibility front and center.

PR comment automation

A pull request should make proposed changes visible before anyone approves them, and the most valuable feedback comes from posting plan output as PR comments. This example configuration adds the plan results directly to your pull request:

- name: Terraform Plan
id: plan
run: terraform plan -no-color -input=false
continue-on-error: true
- name: Comment PR
uses: actions/github-script@v7
if: github.event_name == 'pull_request'
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const output = `#### Terraform Plan \`${{ steps.plan.outcome }}\`
<details markdown="1"><summary>Show Plan</summary>
\`\`\`terraform
${{ steps.plan.outputs.stdout }}
\`\`\`
</details>
*Pusher: @${{ github.actor }}, Action: \`${{ github.event_name }}\`*`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: output
});

Pro Tip: Including continue-on-error: true ensures the workflow continues even if the plan fails, so you still get feedback about what went wrong.

Status badges for build health

You can also add status badges to your repository README to show the current deployment status:

![Terraform](https://github.com/your-org/your-repo/actions/workflows/terraform.yml/badge.svg)

For more detailed status tracking, create environment-specific badges:

![Production](https://github.com/your-org/your-repo/actions/workflows/terraform.yml/badge.svg?branch=main)
![Staging](https://github.com/your-org/your-repo/actions/workflows/terraform.yml/badge.svg?branch=staging)

The best practices for Terraform automation in CI

Warning: A poorly designed pipeline can deploy broken infrastructure faster than any human ever could. It helps if you use some well-known practices. Here are some:

Separate plan and apply permissions

The plan job needs read-only access, the apply job needs write access. Never give plan operations modification rights:

# Plan job - read only
- name: Plan
env:
TF_VAR_read_only: true

Use environment promotion patterns

Deploy from a dev environment to staging to production. Each environment gets its own state file and branch protection rules:

dev/terraform.tfstate
staging/terraform.tfstate
prod/terraform.tfstate

Lock down apply operations

Automatic plans are safe. Automatic applies are dangerous. It's best to require manual approval gates scoped to a specific environment before anything reaches production.

The job references the environment by name; the required-reviewers gate itself is configured on that environment in the repository's Settings → Environments, not in the workflow file:

environment:
name: production

Validate early and often, and catch errors before expensive operations

A terraform validate run catches structural errors (e.g., type mismatches, invalid references between resources, and broken interpolation) before anything reaches the plan stage, independent of fmt, which only checks formatting, and independent of a security scanner like tfsec, which checks the plan's content rather than its syntax.

Run all three, in that order, since each one is cheap and each catches a different class of mistake:

- run: terraform fmt -check
- run: terraform validate
- run: tfsec .

Scan with tfsec or Checkov to catch a different class of mistake again: not syntax, not formatting, but a security-relevant misconfiguration (a public S3 bucket, an overly permissive security group) that both fmt and validate would wave through.

For more best practices, refer to the Terraform Best Practices For CI/CD Pipelines guide.

Finalizing your YAML workflow

Your complete pipeline should handle the full development lifecycle: format checking, pull request planning, and applying on merge. Here's what the finished workflow could look like:

name: Terraform CI/CD
on:
pull_request:
paths: ['**.tf', '**.tfvars']
push:
branches: [main]
permissions:
id-token: write
contents: read
pull-requests: write
jobs:
terraform:
runs-on: ubuntu-latest
environment: ${{ github.ref == 'refs/heads/main' && 'production' || 'development' }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::${{ vars.AWS_ACCOUNT_ID }}:role/GitHubActionsTerraformRole
aws-region: ${{ vars.AWS_REGION }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.11.0"
- name: Terraform Format
run: terraform fmt -check
- name: Terraform Init
run: terraform init
- name: Terraform Validate
run: terraform validate
- name: Terraform Plan
id: plan
run: terraform plan -no-color -input=false
continue-on-error: true
- name: Comment PR
uses: actions/github-script@v7
if: github.event_name == 'pull_request'
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `#### Terraform Plan \`${{ steps.plan.outcome }}\`
<details markdown="1"><summary>Show Plan</summary>
\`\`\`terraform
${{ steps.plan.outputs.stdout }}
\`\`\`
</details>`
});
- name: Terraform Apply
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: terraform apply -auto-approve

Your team opens pull requests and immediately sees plan output as comments, no more guessing what infrastructure changes will happen. Format and validation errors get caught before human review. When the PR merges to main, changes apply automatically to production.

Replace ${{ vars.AWS_ACCOUNT_ID }} and ${{ vars.AWS_REGION }} with your actual values in GitHub repository variables. Add multiple environments by duplicating the workflow with different branch triggers. For staging deployments, change the branch condition to the staging environment branch instead of main.

The same workflow runs against Google Cloud with two substitutions: swap the Configure AWS credentials step for the google-github-actions/auth step from the previous section, and point the backend block at your GCS bucket instead of S3.

None of the rest is cloud-specific, so the fmt/validate/plan sequence, the PR comment step, and the conditional apply all stay identical; it's Terraform and GitHub Actions talking to each other, with the cloud credentials as the only variable.

The workflow uses GitHub Environments to control production access. Set up branch protection rules requiring reviews before merge. Add required status checks so broken plans can't reach production.

This foundation scales from simple single-environment setups to complex multi-account deployments. Start here, then add complexity as your infrastructure grows. Keep in mind that you don't need commits or PRs to trigger GitHub Actions workflows; you can also trigger them dynamically with workflow dispatch.

Why choose Stategraph Orchestration

Building this pipeline from scratch works, but it's just a start. As your infrastructure grows, you'll hit limitations that require custom solutions: dependency management between directories, complex approval workflows, and drift detection across multiple environments.

Stategraph Orchestration eliminates this complexity by providing enterprise-grade Terraform automation out of the box, for GitHub and GitLab alike. Instead of maintaining custom GitHub Actions workflows, you get a purpose-built platform that understands Terraform's nuances and runs on the GitHub Actions or GitLab CI runners you already have.

Orchestration automatically implements the security and operational practices covered in this guide. Point an oidc hook at the role or service account from either section above (AWS and GCP are both supported the same way), and plan comments, status tracking, and scheduled drift detection work without the YAML you've just written.

When you modify a shared module, Orchestration automatically determines which downstream directories need replanning, through a depends_on relationship declared once in config, rather than something each engineer has to remember to trigger by hand:

# .stategraph/config.yml
dirs:
network:
when_modified:
file_patterns: ["${DIR}/*.tf"]
applications:
when_modified:
file_patterns: ["${DIR}/*.tf"]
depends_on: 'dir:network'

Simple branch-based deployments evolve into complex approval matrices as teams grow. Orchestration's access control and apply requirements policies handle exactly that: who can plan, who can apply, how many approvals an apply needs, whether merge conflicts or failing status checks block it.

Underneath Orchestration, Stategraph's Infrastructure as a Database replaces the state file itself: your resources and their dependencies live as rows in Postgres rather than one JSON blob behind a single lock, so a plan reads only the subgraph (the blast radius) your change touches, and two changes that don't overlap apply at the same time instead of one queuing behind the other's lock on every other resource in the state.

Policy-as-code enforcement and a full audit trail of every plan and apply come standard. Your hand-built Terraform CI/CD pipeline would need months of additional development to match these capabilities, and even then it wouldn't touch the state-file bottleneck underneath it.

The GitHub Actions approach in this guide teaches you the fundamentals and works well for smaller teams. Orchestration scales those fundamentals to enterprise complexity without the maintenance overhead.

Conclusion

You've learned how to build a production-ready Terraform CI/CD pipeline with GitHub Actions, including OIDC authentication for AWS and Google Cloud, remote state management, and automated plan/apply workflows.

Start with this foundation, then consider Stategraph Orchestration when you need enterprise-scale features without the maintenance burden.

Try Stategraph free · Read the docs

CI/CD pipeline for Terraform FAQs

How do you build a secure CI/CD pipeline for Terraform?

Authenticate with OIDC instead of long-lived cloud keys, so a leaked GitHub Actions log never exposes a reusable credential. Keep the plan job read-only and restrict apply to protected branches with required approvals, pull application secrets from a secret manager rather than plaintext repository secrets, and run terraform fmt, terraform validate, and a security scanner like tfsec before every plan.

What's the best CI/CD workflow for managing Terraform infrastructure changes?

Run terraform plan on every pull request and post the output as a PR comment, so the change is reviewable before anyone approves it. Apply only after merge, or after an explicit approval gate, from a workflow triggered on the protected branch. Promote the same plan, unmodified, from a dev environment through staging to production rather than re-planning fresh in each one.

What CI/CD tools work best with Terraform automation?

GitHub Actions and GitLab CI both handle the mechanics well: checkout, authenticate, init/validate/plan/apply, PR comment. Where a hand-built pipeline runs out of road is cross-directory dependencies, drift detection, and approval policy, which is where a purpose-built orchestrator like Stategraph Orchestration takes over without replacing the CI system underneath it.

How do you automate Terraform plan and apply in CI?

Trigger a plan on pull_request events scoped to your Terraform paths, store plan as a PR comment rather than only in CI logs, and gate apply behind a push to your main branch (or an explicit approval) so nothing reaches your cloud account without review.

Keep plan and apply in separate steps with separate permissions: read-only for plan, write for apply, so a compromised plan job can't modify infrastructure on its own.