Building a CI/CD pipeline for Terraform with GitHub Actions
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:
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:
Next, create a trust policy that allows GitHub to assume your role. Replace YOUR_ORG and YOUR_REPO with your actual values:
Create the IAM role using this trust policy:
Attach the necessary permissions. For Terraform, you typically need broad access, but scope it down for production:
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:
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:
Create the service account your CI runs will impersonate, and grant it the roles/iam.workloadIdentityUser binding scoped to your repository:
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
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:
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:
For multiple environments, organize state files by path:
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:
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:
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:
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:
For more detailed status tracking, create environment-specific badges:
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:
Use environment promotion patterns
Deploy from a dev environment to staging to production. Each environment gets its own state file and branch protection rules:
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:
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:
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.
- Use OIDC for cloud access, a secret manager for runtime values. Never commit sensitive data, not even encrypted.
- Plan for rollbacks. Keep previous configurations in version control. Test rollback procedures in non-production. Terraform rollbacks are complex, so prepare before you need them.
- Monitor state drift with a scheduled job: run
terraform planon a cron trigger against each environment, and alert (a Slack webhook, an opened issue) when the plan shows changes nobody made through the pipeline. A plan that's never empty outside of active development is usually drift, not noise. - Back up state files religiously. S3 versioning (or GCS object versioning) plus cross-region replication. Test your recovery procedures regularly. Corrupted state files end careers!
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:
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:
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.
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.