Skip to main content

Command Palette

Search for a command to run...

Managing GitHub Organizations with Terraform: From Manual Chaos to Infrastructure as Code

Published
10 min read
Managing GitHub Organizations with Terraform: From Manual Chaos to Infrastructure as Code

If you’ve ever managed a GitHub organization with more than a handful of repositories, you know the pain. Click here to add a branch protection rule. Click there to create a team. Navigate through five menus to grant repository access. Repeat. Repeat. Repeat.

Now imagine doing this for 50 repositories. Or 100. Or trying to maintain consistency across them all. Or worse — auditing who has access to what.

There’s a better way: Infrastructure as Code with Terraform.

The Problem with Manual GitHub Management

Manual GitHub organization management doesn’t scale. Here’s what typically happens:

Configuration Drift: Team A protects their main branch with certain rules. Team B uses different rules. Team C forgets to protect their branch at all.

Access Control Chaos: Someone needs access to five repositories. You grant it manually. Six months later, they leave the company. Did you remember to revoke access everywhere?

No Audit Trail: How do you know what changed, when, and by whom? GitHub’s audit log helps, but it doesn’t tell you the desired state of your infrastructure.

Documentation Debt: Your internal wiki has outdated screenshots of “how to configure a repository.” Reality diverged months ago.

Enter Terraform for GitHub

Terraform, the popular Infrastructure as Code tool, has excellent support for GitHub through the official GitHub provider. This means you can define your entire organization structure — repositories, teams, access controls, branch protection rules — in code.

The benefits are immediate:

Version Control: Your GitHub configuration lives in Git (meta, right?)
Code Review: Changes go through pull requests
Auditability: Complete history of what changed and why
Consistency: Define patterns once, apply everywhere
Disaster Recovery: Your org structure is documented in code

A Practical Architecture

I’ve built a reference implementation that demonstrates how to structure Terraform for GitHub management: github-org-management-examples

The architecture uses four independent modules:

1. Organization Configuration

Manages org-level settings like billing email, member privileges, and default permissions. This is your organization’s “constitution” — the baseline rules everyone operates under.

resource "github_organization_settings" "this" {
  billing_email = var.billing_email

  members_can_create_repositories = true
  members_can_create_public_repositories = false
  members_can_create_private_repositories = true

  members_can_fork_private_repositories = false
}

2. Repository Management

Here’s where it gets interesting. Instead of hardcoding repositories in Terraform, define them in YAML:

repositories:
  example-with-ruleset:
    name: "example-with-ruleset"
    description: "Example repository with rulesets"
    visibility: "public"
    has_issues: true
    has_discussions: true
    has_projects: false
    delete_branch_on_merge: true
    topics:
      - "terraform"
      - "github"
      - "rulesets"
    vulnerability_alerts: true
    default_branch: "main"

    # Repository Rulesets (available for public repos on free tier)
    rulesets:
      main-protection:
        name: "Main Branch Protection"
        enforcement: "active"
        target: "branch"
        branch_patterns:
          - "~DEFAULT_BRANCH"  # Matches the default branch

        rules:
          creation: false
          update: true  # Require pull request
          deletion: true  # Block deletion
          required_linear_history: true
          non_fast_forward: true  # Prevent force pushes

          pull_request:
            required_approving_review_count: 1
            dismiss_stale_reviews_on_push: true
            require_code_owner_review: false
            required_review_thread_resolution: true

          required_status_checks:
            strict_required_status_checks_policy: true
            required_checks: []

The Terraform code reads this YAML and creates resources dynamically. This separation is crucial — developers can propose repository changes in YAML without touching Terraform logic.

3. Organization Rulesets

Organization-level rulesets (requires GitHub Team or Enterprise) let you enforce policies across all repositories. Think of it as a safety net — even if someone forgets to configure their repository properly, the org-level rules catch it.

Important: Repository-level rulesets work on the free tier for public repos. Organization-level rulesets require a paid plan but provide centralized enforcement.

4. Team Management

Teams and their repository access permissions, all in YAML:

teams:
  core-team:
    name: "Core Team"
    description: "Core maintainers with full access to the organization"
    privacy: "closed"
    members:
      - username: "alice"
        role: "maintainer"
      - username: "bob"
        role: "member"
    repositories:
      - repository: ".github"
        permission: "admin"

  external-access:
    name: "External Access"
    description: "External collaborators with read access to specific private repositories"
    privacy: "closed"
    members:
      - username: "external-contractor"
        role: "member"
    repositories:
      - repository: "private-repo"
        permission: "pull"  # Read-only access
      - repository: "another-private-repo"
        permission: "push"  # Write access

The Terraform module handles the complexity of creating teams, adding members, and granting repository access — all from this declarative configuration.

Real-World Workflow

Here’s how this looks in practice:

Scenario: New Repository

  1. Developer opens a PR adding the repository to repositories.yaml

  2. Team reviews the configuration (visibility, branch protection, etc.)

  3. PR merges

  4. GitHub Actions runs terraform apply

  5. Repository is created with all protections in place

Scenario: Access Request

  1. Engineer needs access to three repositories

  2. PR adds them to the relevant team in teams.yaml

  3. Security team reviews

  4. Merge triggers Terraform

  5. Access granted consistently across all repos

Scenario: Policy Update

  1. Security requires all repos to enforce signed commits

  2. Update the organization ruleset in org_rulesets.yaml

  3. One PR, one review, one apply

  4. Policy enforced across the entire organization

The YAML Strategy

Why YAML over pure Terraform? Several reasons:

Lower Barrier to Entry: Developers who don’t know Terraform can still propose repository changes. YAML is more approachable than HCL.

Separation of Concerns: Terraform handles the how (API calls, state management). YAML handles the what (desired configuration).

Validation: You can build additional tooling around YAML — linters, validators, custom checks — without modifying Terraform code.

Scalability: When you have 100+ repositories, managing them in YAML is far more maintainable than sprawling Terraform files.

Key Implementation Details

Using try() for Optional Fields

GitHub’s API has many optional parameters. The modules use try() extensively to provide sensible defaults:

resource "github_repository" "repos" {
  for_each = local.repositories

  name        = each.value.name
  description = try(each.value.description, null)
  visibility  = try(each.value.visibility, "private")

  # Features
  has_issues      = try(each.value.has_issues, true)
  has_discussions = try(each.value.has_discussions, false)
  has_projects    = try(each.value.has_projects, true)
  has_wiki        = try(each.value.has_wiki, true)

  # Merge settings
  allow_merge_commit     = try(each.value.allow_merge_commit, true)
  allow_squash_merge     = try(each.value.allow_squash_merge, true)
  delete_branch_on_merge = try(each.value.delete_branch_on_merge, true)

  # Other settings
  topics               = try(each.value.topics, [])
  vulnerability_alerts = try(each.value.vulnerability_alerts, true)
}

This pattern allows YAML configurations to be minimal — only specify what differs from defaults.

Dynamic Ruleset Generation

Repository rulesets can be defined inline with each repository. The locals.tf flattens this structure:

# locals.tf - Flatten rulesets from all repositories
locals {
  repo_rulesets = flatten([
    for repo_key, repo in local.repositories : [
      for ruleset_key, ruleset in try(repo.rulesets, {}) : {
        key         = "${repo_key}-${ruleset_key}"
        repo_key    = repo_key
        repo_name   = repo.name
        ruleset_key = ruleset_key
        ruleset     = ruleset
      }
    ]
  ])
}

# main.tf - Create rulesets dynamically
resource "github_repository_ruleset" "repo_rulesets" {
  for_each = {
    for rs in local.repo_rulesets : rs.key => rs
  }

  repository  = github_repository.repos[each.value.repo_key].name
  name        = each.value.ruleset.name
  target      = try(each.value.ruleset.target, "branch")
  enforcement = try(each.value.ruleset.enforcement, "active")

  conditions {
    ref_name {
      include = try(each.value.ruleset.branch_patterns, ["~DEFAULT_BRANCH"])
      exclude = try(each.value.ruleset.exclude_patterns, [])
    }
  }

  rules {
    creation                = try(each.value.ruleset.rules.creation, false)
    update                  = try(each.value.ruleset.rules.update, true)
    deletion                = try(each.value.ruleset.rules.deletion, true)
    required_linear_history = try(each.value.ruleset.rules.required_linear_history, false)
    non_fast_forward        = try(each.value.ruleset.rules.non_fast_forward, true)

    dynamic "pull_request" {
      for_each = try(each.value.ruleset.rules.pull_request, null) != null ? [1] : []
      content {
        required_approving_review_count   = try(each.value.ruleset.rules.pull_request.required_approving_review_count, 1)
        dismiss_stale_reviews_on_push     = try(each.value.ruleset.rules.pull_request.dismiss_stale_reviews_on_push, true)
        require_code_owner_review         = try(each.value.ruleset.rules.pull_request.require_code_owner_review, false)
      }
    }
  }
}

This creates rulesets only for repositories that define them, keeping the state clean and focused.

Flattened Team Repository Access

The team module uses a clever flattening technique to create individual access resources:

# locals.tf - Flatten team repositories
locals {
  team_repositories = flatten([
    for team_key, team in local.teams : [
      for repo in coalesce(try(team.repositories, null), []) : {
        team_key   = team_key
        repository = repo.repository
        permission = try(repo.permission, "pull")
      }
    ]
  ])
}

# locals.tf - Flatten team members
locals {
  team_members = flatten([
    for team_key, team in local.teams : [
      for member in coalesce(try(team.members, null), []) : {
        team_key = team_key
        username = member.username
        role     = try(member.role, "member")
      }
    ]
  ])
}

# main.tf - Create team repository access
resource "github_team_repository" "team_repos" {
  for_each = {
    for tr in local.team_repositories : "${tr.team_key}-${tr.repository}" => tr
  }

  team_id    = github_team.teams[each.value.team_key].id
  repository = each.value.repository
  permission = each.value.permission
}

# main.tf - Add team members
resource "github_team_membership" "members" {
  for_each = {
    for tm in local.team_members : "${tm.team_key}-${tm.username}" => tm
  }

  team_id  = github_team.teams[each.value.team_key].id
  username = each.value.username
  role     = each.value.role
}

This transforms the hierarchical YAML structure into the flat resource model Terraform needs.

Deployment Strategy

Each module is independent, allowing incremental adoption:

  1. Start Small: Begin with organization settings

  2. Add Repositories: Migrate existing repos to Terraform gradually

  3. Implement Teams: Codify team structure and access

  4. Enforce Policies: Layer in rulesets once the foundation is solid

Use separate state files for each module. This provides isolation — changes to teams don’t affect repository state.

Gotchas and Considerations

Authentication

You’ll need either a Personal Access Token (PAT) or GitHub App credentials. For production, use GitHub Apps with fine-grained permissions.

provider "github" {
  owner = var.github_organization
  token = var.github_token  # Better: use app authentication
}

State Management

Terraform state contains sensitive information. Use remote state (Terraform Cloud, S3 with encryption, etc.) and restrict access appropriately.

Import Existing Resources

Migrating existing infrastructure requires importing resources:

terraform import 'github_repository.this["backend-api"]' backend-api
terraform import 'github_team.this["backend-team"]' 12345678

Build an import script if you have many resources to migrate.

Plan Limitations

Organization rulesets require GitHub Team or Enterprise. Repository rulesets work on free tier for public repos. Plan accordingly based on your GitHub tier.

CI/CD Integration

While GitHub Actions works well, Atlantis is often the better choice for Terraform automation — and arguably the recommended approach for managing infrastructure changes. Atlantis provides a GitOps workflow where Terraform runs are triggered and reviewed directly in pull requests, with built-in locking, approval workflows, and plan/apply separation.

The benefits of Atlantis include:

  • Pull request-native workflow — Plans and applies happen in PR comments

  • State locking — Prevents concurrent modifications

  • Approval gates — Require explicit approval before apply

  • Audit trail — Everything happens in GitHub, fully visible

  • Multi-environment support — Manage dev/staging/prod with different approval rules

For a production setup managing critical GitHub infrastructure, Atlantis provides the guardrails and visibility you need. The setup requires running an Atlantis server, but the operational benefits are well worth it.

GitHub Actions Approach

Automate Terraform runs with GitHub Actions:

name: Terraform Apply

on:
  push:
    branches: [main]
    paths:
      - 'repos/**'
      - 'teams/**'

jobs:
  terraform:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3

      - name: Terraform Init
        run: terraform init
        working-directory: ./repos

      - name: Terraform Apply
        run: terraform apply -auto-approve
        env:
          GITHUB_TOKEN: ${{ secrets.TF_GITHUB_TOKEN }}
        working-directory: ./repos

Add terraform plan on pull requests for preview:

on:
  pull_request:
    paths:
      - 'repos/**'

# ... terraform plan output as PR comment

Security Considerations

Least Privilege: Grant Terraform only the permissions it needs. Use GitHub Apps over PATs for fine-grained control.

Secret Scanning: Enable secret scanning on your Terraform repository. Never commit tokens.

State File Security: Terraform state contains sensitive data. Encrypt it at rest and in transit.

Review Process: Require multiple approvals for Terraform PRs. Organization changes should never be one person’s decision.

Drift Detection: Run terraform plan regularly to detect manual changes. Set up alerts if drift is detected.

Beyond the Basics

Once you have the foundation, you can extend it:

  • Custom Modules: Create organization-specific abstractions

  • Validation: Build custom validators for YAML configurations

  • Documentation Generation: Auto-generate docs from Terraform state

  • Compliance Reports: Generate access reports for audits

  • Batch Operations: Bulk update repositories using Terraform’s for_each

The Full Picture

Managing GitHub with Terraform isn’t just about automation — it’s about treating your organization structure as code. Version control, code review, automated testing, and deployment pipelines all apply.

The result is a GitHub organization that’s:

  • Consistent: Every repository follows the same standards

  • Auditable: Complete history of every change

  • Recoverable: Disaster recovery is just terraform apply

  • Scalable: Adding your 100th repository is as easy as the first

  • Secure: Policies are enforced automatically, not manually

Getting Started

Check out the complete implementation: github-org-management-examples

The repository includes:

Start with one module, validate the approach, then expand. Your future self — and your team — will thank you.

Have you managed GitHub organizations with Terraform? What challenges did you face? Share your experience in the comments.