Skip to content
ExponaBLOG
TERRAFORM, IAC & CLOUD AUTOMATION · DAY 063 / 100

Managing GitHub with Terraform: best practices explained

Repositories and teams as code.

Expona Admin10 min read

Day 63: Managing GitHub with Terraform

By day 63 of your infrastructure-as-code journey, you've likely deployed networks, databases, and compute resources through Terraform. But your infrastructure lives alongside code, and that code lives in GitHub. Managing GitHub with Terraform: best practices explained gives you the same declarative control over repositories, teams, and security policies that you've applied to cloud resources—letting your team enforce consistency at the source.

GitHub repositories and their configurations represent a critical piece of your infrastructure. When you configure branch protection rules, team permissions, and repository settings manually through the web UI, you create drift, inconsistency, and onboarding friction. By treating GitHub as code, you document decisions, enable peer review of access changes, and prevent the security configuration drift that catches teams off guard during audits.

GitHub Provider for Repos and Teams

The Terraform GitHub provider lets you define repositories, teams, and memberships as resource blocks. This moves GitHub from a tool you configure manually to a system you version control, audit, and replicate.

Start with a simple repository definition. You'll specify the repository name, description, visibility, and default branch. Here's a concrete example:

hcl
terraform {
  required_providers {
    github = {
      source  = "integrations/github"
      version = "~> 6.0"
    }
  }
}

provider "github" {
  token = var.github_token
  owner = var.github_org
}

resource "github_repository" "api_service" {
  name        = "api-service"
  description = "Core API for payment processing"
  visibility  = "private"

  has_issues             = true
  has_discussions        = false
  has_projects           = true
  has_wiki               = false
  is_template            = false
  delete_branch_on_merge = true

  topics = ["api", "payments", "production"]
}

Now add team management. Teams in GitHub organize people and define permissions across repositories. Define a team and its members:

hcl
resource "github_team" "backend_platform" {
  name        = "backend-platform"
  description = "Backend and platform engineers"
  privacy     = "closed"
}

resource "github_team_membership" "backend_lead" {
  team_id  = github_team.backend_platform.id
  username = "alice"
  role     = "maintainer"
}

resource "github_team_membership" "backend_engineer" {
  team_id  = github_team.backend_platform.id
  username = "bob"
  role     = "member"
}

resource "github_team_repository" "api_access" {
  team_id    = github_team.backend_platform.id
  repository = github_repository.api_service.name
  permission = "push"
}

This declares that the backend-platform team has push access to the api-service repository. Alice is the maintainer (can manage team membership), and Bob is a regular member. The explicit declaration makes it obvious who has what access—something that's easy to overlook in the GitHub web UI after a few quarters of growth.

Use variables to keep your Terraform portable across environments and organizations:

hcl
variable "github_token" {
  type        = string
  sensitive   = true
  description = "GitHub personal access token with repo and admin:org_hook scopes"
}

variable "github_org" {
  type        = string
  description = "GitHub organization name"
}

variable "team_members" {
  type = map(object({
    role     = string
    teams    = list(string)
  }))
  description = "Map of GitHub usernames to their teams and roles"
}

Pass these through a terraform.tfvars file (gitignored) or environment variables. Never commit tokens or sensitive data.

Branch Protection Rules

Branch protection rules prevent accidental damage to production-critical branches. With Terraform, you define these rules once and enforce them across all repositories in your organization, eliminating the "I forgot to enable that" gap.

A typical branch protection rule requires code reviews, status checks, and dismissal of old reviews when new commits are pushed:

hcl
resource "github_branch_protection" "main" {
  repository_id            = github_repository.api_service.node_id
  pattern                  = "main"
  enforce_admins           = true
  require_signed_commits   = true
  dismiss_stale_pr_approvals = true

  required_status_checks {
    strict   = true
    contexts = [
      "build",
      "test",
      "security-scan"
    ]
  }

  required_pull_request_reviews {
    dismiss_stale_reviews           = true
    require_code_owner_reviews      = true
    required_approving_review_count = 2
    restrict_dismissals             = true
  }

  restrictions {
    users = ["automation-bot"]
  }
}

This rule ensures that:

  • All commits to main are signed (commit signature verification enabled)
  • Two independent approvals are required before merge
  • Status checks (CI/CD pipelines) must pass
  • Old reviews are dismissed if new commits arrive
  • Only the automation-bot can bypass these rules (useful for automated releases)
  • Admins must follow the same rules

Create a locals block to define a standard rule set and apply it consistently:

hcl
locals {
  standard_protection = {
    enforce_admins           = true
    require_signed_commits   = true
    dismiss_stale_pr_approvals = true
    required_approvals       = 2
  }
}

resource "github_branch_protection" "production_branches" {
  for_each = toset([
    github_repository.api_service.node_id,
    github_repository.web_frontend.node_id,
    github_repository.worker_service.node_id,
  ])

  repository_id              = each.value
  pattern                    = "main"
  enforce_admins             = local.standard_protection.enforce_admins
  require_signed_commits     = local.standard_protection.require_signed_commits
  dismiss_stale_pr_approvals = local.standard_protection.dismiss_stale_pr_approvals

  required_pull_request_reviews {
    required_approving_review_count = local.standard_protection.required_approvals
    dismiss_stale_reviews           = true
  }

  required_status_checks {
    strict   = true
    contexts = ["build", "test", "security-scan"]
  }
}

Now all three repositories have identical protection rules. If you need to increase the approval count to 3, you update local.standard_protection.required_approvals once, and the change cascades to all protected branches on next apply.

Consistent Settings Across Repositories

Most organizations have common security and operational requirements: require signed commits, delete head branches after merge, configure auto-delete of old deployments. Rather than configuring each repository individually, define a module that encodes these standards.

Create a module file modules/github_repository/main.tf:

hcl
resource "github_repository" "repo" {
  name                   = var.name
  description            = var.description
  visibility             = var.visibility
  delete_branch_on_merge = true
  allow_squash_merge     = true
  allow_rebase_merge     = true
  allow_merge_commit     = true
  merge_commit_title     = "PR_TITLE"
  merge_commit_message   = "PR_BODY"

  topics = concat(["terraform-managed"], var.topics)

  vulnerability_alerts        = true
  web_commit_signoff_required = true
  squash_merge_commit_title   = "PR_TITLE"
  squash_merge_commit_message = "PR_BODY"
}

resource "github_branch_protection" "main" {
  repository_id = github_repository.repo.node_id
  pattern       = "main"

  enforce_admins           = true
  require_signed_commits   = true
  dismiss_stale_pr_approvals = true

  required_pull_request_reviews {
    required_approving_review_count = var.required_approvals
    dismiss_stale_reviews           = true
    require_code_owner_reviews      = var.require_code_owner_reviews
  }

  required_status_checks {
    strict   = true
    contexts = var.required_status_checks
  }
}

resource "github_team_repository" "access" {
  for_each = var.team_permissions

  team_id    = each.value.team_id
  repository = github_repository.repo.name
  permission = each.value.permission
}

Define the module's variables in modules/github_repository/variables.tf:

hcl
variable "name" {
  type = string
}

variable "description" {
  type = string
}

variable "visibility" {
  type    = string
  default = "private"
}

variable "topics" {
  type    = list(string)
  default = []
}

variable "required_approvals" {
  type    = number
  default = 2
}

variable "require_code_owner_reviews" {
  type    = bool
  default = true
}

variable "required_status_checks" {
  type    = list(string)
  default = ["build", "test", "lint"]
}

variable "team_permissions" {
  type = map(object({
    team_id    = string
    permission = string
  }))
  default = {}
}

Now use the module for each repository:

hcl
module "api_service" {
  source = "./modules/github_repository"

  name        = "api-service"
  description = "Core API for payment processing"
  visibility  = "private"
  topics      = ["api", "payments"]

  required_approvals = 2
  required_status_checks = [
    "build",
    "test",
    "security-scan",
    "integration-tests"
  ]

  team_permissions = {
    backend-platform = {
      team_id    = github_team.backend_platform.id
      permission = "push"
    }
    devops = {
      team_id    = github_team.devops.id
      permission = "maintain"
    }
  }
}

module "web_frontend" {
  source = "./modules/github_repository"

  name        = "web-frontend"
  description = "Customer-facing React application"
  visibility  = "private"
  topics      = ["react", "frontend"]

  required_status_checks = [
    "build",
    "test",
    "lint",
    "visual-regression"
  ]

  team_permissions = {
    frontend-team = {
      team_id    = github_team.frontend_team.id
      permission = "push"
    }
    devops = {
      team_id    = github_team.devops.id
      permission = "maintain"
    }
  }
}

This approach ensures every repository inherits the same security baseline—commit signatures, review requirements, branch protection—while allowing team-specific customization. When your security posture evolves, you update the module once, and all consuming repositories align on the next apply.

Review Permission Changes in PRs

Access control changes are sensitive. A typo could grant an external contributor admin permissions, or accidentally lock your entire team out of a repository. Always review GitHub configuration changes in pull requests before applying them to production.

Store your GitHub Terraform code in a repository with branch protection enabled:

  • Require two approvals for any PR that modifies team membership or repository permissions
  • Require specific code owners (perhaps your security team) to approve access changes
  • Use a CODEOWNERS file to enforce this:
hcl
# .github/CODEOWNERS
# Access control changes require security team review
**/github/team*.tf @org/security-team
**/github/permissions*.tf @org/security-team

In your CI/CD pipeline, use terraform plan to surface what will change. A careful reviewer should see the diff before any apply:

hcl
name: Terraform Plan

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

jobs:
  plan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v2
        with:
          terraform_version: 1.7.0

      - run: terraform init
        working-directory: github

      - run: terraform plan -out=tfplan
        working-directory: github
        env:
          GITHUB_TOKEN: ${{ secrets.TERRAFORM_GITHUB_TOKEN }}

      - run: terraform show tfplan > plan-output.txt
        working-directory: github

      - uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const plan = fs.readFileSync('github/plan-output.txt', 'utf8');
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: '```\n' + plan + '\n```'
            });

This posts the full plan as a PR comment so reviewers see exactly what permissions will change. Pair this with required approvals, and you have an audit trail: every access change was reviewed and approved before deployment.

Common Mistakes

Storing tokens in version control. GitHub tokens grant full API access. Use Terraform variables, environment variables, or a secrets manager. Never commit them.

Manually modifying repositories after Terraform apply. If someone configures a branch protection rule through the GitHub UI and you later import the resource or apply Terraform, you risk unexpected changes. Communicate to your team: GitHub infrastructure is managed by Terraform; all changes go through Terraform and PR review.

Not setting enforce_admins to true. This is tempting when you want to quickly bypass your own branch protection rules. Resist it. If you need to deploy urgently, merge through the normal process or use an emergency approval workflow, but don't make yourself exempt—it weakens the rule for everyone.

Forgetting to require signed commits. Commit signatures are a low-cost way to verify that commits are actually from who claims to have written them. Set require_signed_commits = true on all production branches.

Not versioning your GitHub Terraform code. Treat your GitHub infrastructure with the same rigor as your cloud infrastructure. Use a separate repository, enable branch protection on it, and run it through CI/CD. This is not your application code; it's your access control policy.

Key Takeaways

  • Use the GitHub Terraform provider to define repositories, teams, and permissions as code, enabling version control and audit trails for all access changes.
  • Branch protection rules enforce consistent security policies—two approvals, signed commits, passing CI/CD—across all repositories without manual configuration drift.
  • Build a reusable module for standard repository creation so new projects inherit security baselines automatically rather than repeating configuration.
  • Always review GitHub permission changes in pull requests with required approvals and CODEOWNERS enforcement before applying to production.
  • Store your GitHub Terraform code in a separate, protected repository and run it through the same CI/CD rigor as your infrastructure code.
  • Communicate clearly to your team that GitHub configuration is managed by Terraform; resist manual UI changes to avoid drift and audit gaps.
  • Use a secrets manager or environment variables for tokens; never commit them to version control.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles