How to Use GitOps with Terraform: A Real-World Guide
Infrastructure should be managed like application code. But for years, Terraform workflows looked nothing like modern software development.
Picture this: A developer runs terraform plan
locally, applies changes to production, then maybe commits to Git afterward. No code review, no approval process, no audit trail. Meanwhile, your application code goes through pull requests, CI/CD pipelines, and peer review before touching production.
This disconnect created real problems. Infrastructure drifted from code. Changes happened without oversight. Credentials got copy-pasted around. Teams lost track of who changed what and when.
GitOps bridges this gap. It treats infrastructure changes exactly like application deployments: pull requests for review, automated testing in CI/CD, approval gates before production, and OIDC for secure authentication. Every change is traceable.
It's not revolutionary technology, it's Git, CI, Terraform, and the same discipline your development teams already practice. The only difference is applying those proven patterns to infrastructure.
Modern teams adopted GitOps not because it was trendy, but because they were tired of managing infrastructure like it was 2010 while their applications lived in 2024.
The problems GitOps solves
Common pain points:
-
Terraform apply from local machines: Local applies mean credentials, secrets, and state can be spread across laptops. There's no control over who applied what, and no central logs.
-
No auditability or review process: Without a pull-request-driven flow, you lose critical review gates. There's no enforced approval process, no historical trace, and no way to verify intent behind infrastructure changes.
-
Drift between actual infrastructure and what's in Git: Teams often discover production resources that were never defined in code-or worse, defined incorrectly and overwritten. Without reconciliation or automated drift detection, it's easy for environments to fall out of sync.
-
Inconsistent CI/CD implementations: One repo uses
terraform init
&&terraform plan
, another runs outdated shell scripts, and yet another requires SSHing into a Jenkins box. Secrets are manually injected, and pipelines are snowflakes-hard to reproduce, harder to scale.
GitOps changes that
GitOps changes that by enforcing a single source of truth: Git.
Under GitOps, infrastructure changes must go through pull requests. Your CI pipeline runs terraform plan
, posts the output to the PR, and waits for human approval. Once merged, automation runs terraform apply
-using secure credentials via GitHub OIDC or similar-and logs everything.
This model brings:
- Centralized workflows
- Immutable audit trails
- Standardized applies
- Drift detection
It's not new technology. It's just Git, Terraform, CI, and some discipline-glued together to enforce predictability and scale. That's why infra teams are adopting GitOps now: because it's solving the problems they've been living with for years.
What GitOps actually means for infrastructure teams
Now that we've seen why GitOps started showing up in infrastructure conversations, let's unpack what it actually means in practice.
At its core, GitOps is about applying the same operational discipline we already use for code: version control, peer review, automated pipelines. No new toolchain, just a clear model: Git as the source of truth, pull requests as the gate, and automation as the executor.
The core principles
1. Git is the source of truth
If something's not in Git, it doesn't exist. That's the baseline. Your Terraform modules, your environment configs, even your CI workflows-everything is tracked. This gives you a single place to audit, a single place to debug, and a clear version history of every infrastructure change.
2. Pull requests drive infrastructure changes
GitOps enforces change through pull requests. That means no one's pushing to production without a diff being reviewed. CI pipelines run terraform plan
on the PR and post the output for everyone to see. It's not just about catching mistakes-it's about enforcing accountability and knowledge sharing.
3. Automation handles execution
There's no running terraform apply
from a terminal anymore. The apply happens via CI once the pull request is merged. Credentials are injected securely-usually via GitHub OIDC and IAM roles-so there's no long-lived access tokens, no one-off scripts, and no finger-pointing when things break.
4. Continuous reconciliation
With GitOps, the live environment is supposed to match what's in Git at all times. If a resource drifts (maybe someone made a manual change in the cloud), that drift gets detected. Depending on your setup, it can be flagged for review or auto-corrected. Either way, Git becomes the truth again.
GitOps is more than just Kubernetes
-
Terraform (or Pulumi, Terragrunt, CDKTF): You define your infrastructure as code, store it in Git, and structure it around pull requests and CI pipelines. Each PR runs
terraform plan
in CI. On merge,terraform apply
happens automatically in a secure environment, using short-lived credentials (OIDC via GitHub Actions, for example). -
Cloud-native tools like CloudFormation or Bicep: Same pattern. You store your templates in Git, validate changes via CI, and only deploy through automation. You can gate changes with policies (like OPA or Conftest), and ensure production is only touched after approvals.
-
Managing secrets, access, and state: This is often what makes or breaks GitOps for infra. You use backends like S3 for Terraform state, and remove all manual access. Secrets never touch the repo-they're stored in vaults (AWS Secrets Manager, SOPS, etc.) and accessed only during runtime.
-
Parallel workflows for different environments: GitOps lets you split workflows cleanly: one branch per environment, or separate folders/modules with isolated backends. You gate
prod
more strictly thandev
, and limit blast radius by design.
GitOps isn't just for deploying containers. It's a natural fit for managing infrastructure across any cloud, any stack.
And once you get the basics working with Terraform and GitHub Actions, you'll see why teams start standardizing everything around this model.
How do you set up a basic GitOps workflow with Terraform and GitHub Actions?
Now that we've established that GitOps isn't limited to Kubernetes and fits perfectly with infrastructure-as-code workflows, let's break down what it looks like in practice, specifically with Terraform and GitHub Actions.
This is the "roll-your-own" approach: no platform dependencies, no magic wrappers. Just you, your code, and GitHub. It's especially useful for teams who want to stay close to the underlying tools and maintain full control over their pipelines, secrets, and deployment logic.
1. Example repository
The following directory tree lays out a typical repository with Terraform code.
├── envs/
│ ├── prod/
│ │ └── main.tf
│ └── dev/
│ └── main.tf
├── modules/
│ ├── network/
│ └── compute/
└── .github/
└── workflows/
├── plan.yml
└── apply.yml
Key Points:
envs/
holds your environment-specific configurationsmodules/
contains reusable Terraform modules.github/workflows/
holds your CI pipeline definitions
Organizing environments in directories is a great way to create isolation. This keeps state files isolated and avoids blast radius.
2. CI Pipeline: Terraform Plan on Pull Request
Your first workflow should trigger on pull requests to the main
(or prod
) branch. The goal is to run terraform init
and terraform plan
, then post the plan output back to the PR for review.
# .github/workflows/plan.yml
name: Terraform Plan
on:
pull_request:
paths:
- 'envs/**'
jobs:
plan:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
pull-requests: write
strategy:
matrix:
include:
- environment: dev
directory: envs/dev
- environment: prod
directory: envs/prod
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check if environment changed
id: check-changes
run: |
# Check if any files in this specific environment directory changed
if git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -q "^${{ matrix.directory }}/"; then
echo "changed=true" >> $GITHUB_OUTPUT
echo "Directory ${{ matrix.directory }} has changes"
else
echo "changed=false" >> $GITHUB_OUTPUT
echo "Directory ${{ matrix.directory }} has no changes"
fi
- name: Set up Terraform
if: steps.check-changes.outputs.changed == 'true'
uses: hashicorp/setup-terraform@v3
with:
terraform_wrapper: false
- name: Terraform Init
if: steps.check-changes.outputs.changed == 'true'
run: terraform init
working-directory: ${{ matrix.directory }}
- name: Terraform Plan
if: steps.check-changes.outputs.changed == 'true'
id: plan
run: |
set +e
terraform plan -no-color -out=tfplan 2>&1 | tee plan_output.txt
echo "exit_code=${PIPESTATUS[0]}" >> $GITHUB_OUTPUT
set -e
working-directory: ${{ matrix.directory }}
- name: Post Plan to PR
if: steps.check-changes.outputs.changed == 'true'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const path = require('path');
const directory = '${{ matrix.directory }}';
const environment = '${{ matrix.environment }}';
const exitCode = '${{ steps.plan.outputs.exit_code }}';
const planPath = path.join(directory, 'plan_output.txt');
let planOutput = '';
try {
planOutput = fs.readFileSync(planPath, 'utf8');
} catch (error) {
planOutput = `Error reading plan output: ${error.message}`;
}
const status = exitCode === '0' ? '✅ Plan Succeeded' : '❌ Plan Failed';
const icon = exitCode === '0' ? '✅' : '❌';
// Truncate if too long
const maxLength = 40000;
const truncatedPlan = planOutput.length > maxLength ?
planOutput.substring(0, maxLength) + '\n\n... [Output truncated - view full output in Actions logs]' :
planOutput;
const body = `### ${icon} Terraform Plan: \`${environment}\` environment
**Status:** ${status}
**Directory:** \`${directory}\`
**Exit Code:** ${exitCode}
<details>
<summary>📋 Show Plan Output</summary>
\`\`\`hcl
${truncatedPlan}
\`\`\`
</details>
---
*Triggered by changes in \`${directory}\`*`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: body
});
This keeps reviewers in the loop, allows for early feedback, and ensures nothing gets merged without visibility.
3. CI pipeline: Terraform Apply on merge
Once a PR is reviewed and merged, a separate workflow handles the actual terraform apply
. This is where infrastructure changes are made.
# .github/workflows/apply.yml
# .github/workflows/apply.yml
name: Terraform Apply
on:
push:
branches:
- main
- master
paths:
- 'envs/**'
jobs:
apply:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
strategy:
matrix:
include:
- environment: dev
directory: envs/dev
- environment: prod
directory: envs/prod
- environment: staging
directory: envs/staging
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Check if environment changed
id: check-changes
run: |
# Check if any files in this specific environment directory changed in the last commit
if git diff --name-only HEAD~1 HEAD | grep -q "^${{ matrix.directory }}/"; then
echo "changed=true" >> $GITHUB_OUTPUT
echo "Directory ${{ matrix.directory }} has changes"
else
echo "changed=false" >> $GITHUB_OUTPUT
echo "Directory ${{ matrix.directory }} has no changes"
fi
- name: Set up Terraform
if: steps.check-changes.outputs.changed == 'true'
uses: hashicorp/setup-terraform@v3
with:
terraform_wrapper: false
- name: Terraform Init
if: steps.check-changes.outputs.changed == 'true'
run: terraform init
working-directory: ${{ matrix.directory }}
- name: Terraform Apply
if: steps.check-changes.outputs.changed == 'true'
id: apply
run: |
set +e
terraform apply -auto-approve -no-color 2>&1 | tee apply_output.txt
echo "exit_code=${PIPESTATUS[0]}" >> $GITHUB_OUTPUT
set -e
working-directory: ${{ matrix.directory }}
- name: Find PR for this commit
if: steps.check-changes.outputs.changed == 'true'
id: find-pr
uses: actions/github-script@v7
with:
script: |
const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'closed',
sort: 'updated',
direction: 'desc',
per_page: 10
});
for (const pr of prs) {
if (pr.merge_commit_sha === context.sha) {
console.log(`Found PR #${pr.number} for commit ${context.sha}`);
return pr.number;
}
}
console.log(`No PR found for commit ${context.sha}`);
return null;
- name: Post Apply Result to PR
if: steps.check-changes.outputs.changed == 'true' && steps.find-pr.outputs.result != 'null'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const path = require('path');
const directory = '${{ matrix.directory }}';
const environment = '${{ matrix.environment }}';
const exitCode = '${{ steps.apply.outputs.exit_code }}';
const prNumber = ${{ steps.find-pr.outputs.result }};
const applyPath = path.join(directory, 'apply_output.txt');
let applyOutput = '';
try {
applyOutput = fs.readFileSync(applyPath, 'utf8');
} catch (error) {
applyOutput = `Error reading apply output: ${error.message}`;
}
const status = exitCode === '0' ? '✅ Apply Succeeded' : '❌ Apply Failed';
const icon = exitCode === '0' ? '✅' : '❌';
// Truncate if too long
const maxLength = 40000;
const truncatedOutput = applyOutput.length > maxLength ?
applyOutput.substring(0, maxLength) + '\n\n... [Output truncated - view full output in Actions logs]' :
applyOutput;
const body = `### ${icon} Terraform Apply: \`${environment}\` environment
**Status:** ${status}
**Directory:** \`${directory}\`
**Exit Code:** ${exitCode}
<details>
<summary>📋 Show Apply Output</summary>
\`\`\`hcl
${truncatedOutput}
\`\`\`
</details>
---
*Applied changes from \`${directory}\`*`;
github.rest.issues.createComment({
issue_number: prNumber,
owner: context.repo.owner,
repo: context.repo.repo,
body: body
});
This setup makes sure that applies only happen after a review and a successful merge. All changes are logged, reproducible, and traceable.
GitOps Benefits
This basic GitOps setup with Terraform and GitHub Actions gives you:
- Secure, repeatable deployments
- Auditable change history
- PR-based approvals and reviews
- Clean separation of environments
- Full control over every step
It's what infra teams have been asking for all along. No third-party GitOps platform. No black boxes. Just Terraform, GitHub Actions, and a sane delivery model. Once the basics are in place, you can layer in policy checks, module validation, drift detection, even preview environments.
Terrateam: GitOps without the glue code
Terrateam is GitOps-native and built for Terraform. It runs plan and apply through pull requests, enforces policy, and manages workflows inside your version control system. Today that is GitHub. Soon it'll be GitLab, Bitbucket, and Azure DevOps. There are no brittle workflow files required. Just infrastructure that flows through Git.

How Terrateam works
When a developer opens a PR with Terraform changes, Terrateam automatically runs a terraform plan
and posts the output as a comment in the pull request.
Once the PR is approved and merged, it handles the apply
- safely gated behind GitHub branch protections, RBAC, and configurable workflows.
There's no need to write custom scripts or wire together a dozen GitHub Actions. You define everything in a simple YAML config at the root of your repo, and Terrateam takes care of the rest.

Key Features
Concurrency management
It also solves the concurrency problem that plagues many teams. When multiple developers are working on infrastructure in parallel, you risk clobbering state or creating drift within your infra. Terrateam prevents this by locking directories or workspaces automatically during an apply. If another PR tries to run an apply on the same target, it waits its turn. It's one of those details that seems small - until you've seen a production outage caused by two concurrent changes.
Environment-Specific workflows
You can also define separate workflows per environment. Dev and staging might apply on merge, while production applies only with explicit approval from a specific team. Each environment can use its own backend configuration, variable sets, and policies. It's easy to map your org's existing SDLC into Terrateam's model.
Security & Policy enforcement
Security is another area where Terrateam goes beyond what you'd typically script by hand. It uses GitHub's OIDC support to fetch short-lived credentials for your cloud provider, so you don't have to manage or rotate static secrets in CI. You also get full Open Policy Agent integration, so you can write and enforce custom rules using Rego - for example, blocking untagged resources or enforcing naming conventions - before changes ever make it to production.
Drift Detection
Even drift detection is built in. Terrateam can regularly check whether your deployed infrastructure still matches the Terraform state, and notify you when something goes out of sync. This helps catch config drift early, especially in teams where changes through cloud's console sometimes sneak in.
Integration & Deployment
Everything runs inside GitHub. There's no extra dashboard to manage, no context switching. And for teams that need tighter control, Terrateam is open source under the MPL-2.0 license and offers a self-hosted deployment model.
Why choose Terrateam
In practice, Terrateam makes Terraform workflows predictable and safe. Developers open pull requests, reviewers approve, and Terrateam handles the rest - automated plans, gated applies, locking, auditability, and policy enforcement. It's Terraform GitOps without the guesswork or glue code.
If your team is already using GitHub, adding Terrateam is a straight path to better automation, tighter controls, and fewer surprises in production.