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:
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:
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:
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:
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:
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:
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:
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:
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:
# .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:
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.






Comments
Loading comments…