June 1, 2025malcolm-matalka

How to Use GitOps with Terraform: A Real-World Guide

A practical guide to structuring Terraform workflows with GitOps, GitHub Actions, and short-lived credentials without glue code or magic.

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 than dev, 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 configurations
  • modules/ 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.

Terrateam status checks

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.

Terrateam plan

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.

👉 https://github.com/terrateamio/terrateam