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
Developer opens a PR adding the repository to
repositories.yamlTeam reviews the configuration (visibility, branch protection, etc.)
PR merges
GitHub Actions runs
terraform applyRepository is created with all protections in place
Scenario: Access Request
Engineer needs access to three repositories
PR adds them to the relevant team in
teams.yamlSecurity team reviews
Merge triggers Terraform
Access granted consistently across all repos
Scenario: Policy Update
Security requires all repos to enforce signed commits
Update the organization ruleset in
org_rulesets.yamlOne PR, one review, one apply
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:
Start Small: Begin with organization settings
Add Repositories: Migrate existing repos to Terraform gradually
Implement Teams: Codify team structure and access
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
Atlantis: The Recommended Approach
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 applyScalable: 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:
Four modular Terraform configurations
YAML-based configuration examples
Detailed README with usage instructions
Documentation website: github-org-management-examples.automationdojo.org
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.



