A Terraform module for managing GitHub repositories at scale with configurable presets, standardized naming patterns, and secure branch protection defaults.
- Preset System: Reusable configurations you define once and apply per repository
- Standardized Naming: Apply organization-wide naming patterns with
repository_naming - Safe Renaming: Rename repositories without Terraform resource recreation
- Branch Protection: Flat, intuitive configuration with automatic code owner review enforcement
- Performance Optimization: Central ID resolution - governance module pre-fetches all team/user/app IDs once and passes them to repository instances, eliminating duplicate API calls
- Hierarchical Architecture: Governance module orchestrates repository submodule for clean separation
.
├── main.tf # Governance orchestration logic
├── variables.tf # Input variables
├── outputs.tf # Module outputs
├── versions.tf # Terraform and provider requirements
├── data.tf # Pre-fetch team/app IDs
├── data.tf # Pre-fetch team/app IDs
├── modules/
│ └── repository/ # Repository submodule
│ ├── main.tf
│ ├── variables.tf
│ ├── outputs.tf
│ ├── versions.tf
│ ├── examples/
│ │ ├── simple/ # Basic repository usage
│ │ └── complete/ # Advanced repository features
│ └── tests/
│ └── repository.tftest.hcl
├── examples/
│ ├── simple/ # Basic governance usage
│ └── complete/ # Advanced governance features
└── tests/
└── governance.tftest.hcl # Governance module tests
module "governance" {
source = "path/to/module"
organization = "my-org"
workspace = "platform"
repository_naming = "%s" # No prefix, use keys as-is
# Define any non-default presets you want to use
presets = {
# Example preset you can reference from repositories
production-service = {
protected_branches = ["main"]
required_approvals = 2
required_checks = ["ci", "security-scan"]
}
}
repositories = {
api-service = {
description = "Main API service"
topics = ["api", "backend"]
}
payment-processor = {
preset = "production-service" # must exist in var.presets
description = "Payment processing service"
}
}
}module "governance" {
source = "path/to/module"
organization = "my-org"
workspace = "microservices"
repository_naming = "myorg-%s" # Prefix: myorg-api-service, myorg-worker, etc.
repositories = {
api-service = {} # Creates "myorg-api-service"
worker = {} # Creates "myorg-worker"
}
}Presets are passed via var.presets (only default exists by default). Define any number of named presets and reference them from repositories with preset = "<name>".
presets = {
default = {
# Optional: override base defaults (private/main, etc.)
}
production-service = {
protected_branches = ["main", "release/*"]
required_approvals = 2
required_checks = ["ci", "security-scan"]
allow_bypass = ["org-admin"]
}
library = {
visibility = "public"
protected_branches = ["main"]
required_checks = ["test", "lint"]
}
}Override specific preset values while keeping the rest:
repositories = {
critical-api = {
preset = "production-service" # Base: 2 approvals
description = "Critical API"
protected_branches = ["main", "release/*"]
required_approvals = 3 # Override: increase to 3
required_checks = ["ci", "security-scan", "integration-tests"]
prevent_force_push = true
prevent_branch_deletion = true
allow_bypass = ["org-admin"]
}
}Rename repositories without Terraform resource recreation:
repositories = {
# Terraform key is stable (never changes)
legacy-auth = {
name = "auth-service-v2" # Actual GitHub name
preset = "production-service"
description = "Authentication service (renamed)"
}
}Renaming Process:
- Keep the Terraform map key stable (e.g.,
legacy-auth) - Add or change the
namefield to the new GitHub repository name - Run
terraform apply- Terraform will rename the repository without destroying/recreating
Add custom metadata to repositories:
repositories = {
api = {
description = "API service"
properties = {
team = "platform"
cost_center = "engineering"
tier = "production"
}
}
}Note: The workspace property is automatically added from workspace.
Create template repositories and use them:
repositories = {
# Define a template
service-template = {
preset = "library"
is_template = true
description = "Template for new services"
}
# Create from template
new-service = {
preset = "staging"
description = "New service from template"
template = {
repository = "my-org/service-template"
include_all_branches = false
}
}
}Configure environment-specific reviewers, secrets, and variables using a flat structure:
repositories = {
api = {
preset = "production-service"
environments = {
production = {
required_approvers = ["team:sre", "team:security"] # reviewers
secrets = {
API_KEY = "prod-secret-value"
}
variables = {
ENV = "production"
}
}
staging = {
secrets = {
API_KEY = "staging-secret-value"
}
variables = {
ENV = "staging"
}
}
}
}
}If required_approvers is omitted or an empty list, no reviewers are enforced for that environment.
- Minimum role: Reviewers (both
team:<slug>anduser:<login>) must have at leastpushpermission on the repository for GitHub to accept them as environment reviewers. - Auto-elevation: This module automatically grants
pushto any reviewer declared inrequired_approverswho does not already meet the minimum. If you need a higher role, set it explicitly inpermissionsand it will be respected. - Ordering stability: Ruleset
bypass_actorsare normalized and sorted by their resolved IDs to avoid cosmetic reordering drift across plans.
The branch-protection inputs are flat and intuitive on each repository:
# Per-repository fields (all optional, secure defaults apply)
protected_branches = ["main", "release/*"]
allow_bypass = ["org-admin", "team:sre"]
required_approvals = 2
required_checks = ["ci", "security-scan"]
prevent_force_push = true
prevent_branch_deletion = trueRuleset behavior:
- If
required_approvals > 0, code owner review and thread resolution are enforced automatically. - Bypass actors can be
org-admin,role:<maintain|write|admin>,team:<slug>, orapp:<slug>.
| Name | Type | Description |
|---|---|---|
organization |
string | GitHub organization name |
workspace |
string | workspace/namespace for logical grouping |
| Name | Type | Default | Description |
|---|---|---|---|
repository_naming |
string | "%s" |
sprintf-style format string for repository names |
repositories |
map(object) | {} |
Map of repositories to create |
presets |
map(object) | { default = {} } |
Named presets you define and reference |
{
preset = optional(string, "default")
name = optional(string) # For renaming
description = optional(string)
visibility = optional(string) # "public", "private", "internal"
default_branch = optional(string)
topics = optional(list(string))
properties = optional(map(string))
is_template = optional(bool)
template = optional(object)
permissions = optional(map(string))
deploy_keys = optional(map(object))
allowed_roles = optional(list(string))
webhooks = optional(map(object))
repository_secrets = optional(map(string)) # or map(object({ value = string, sensitive = optional(bool, true) }))
repository_variables = optional(map(string))
environments = optional(map(object))
# Branch protection (flattened)
protected_branches = optional(list(string))
allow_bypass = optional(list(string))
required_approvals = optional(number)
required_checks = optional(list(string))
prevent_force_push = optional(bool)
prevent_branch_deletion = optional(bool)
}| Name | Description |
|---|---|
repositories |
Map of repositories with id, names, URLs, default_branch, and protected_branches_ruleset_id |
repository_names |
Map of keys to GitHub names |
repository_urls |
Map of keys to HTML URLs |
workspace |
The workspace applied to all repositories |
organization |
The GitHub organization name |
Note: Each submodule instance also exposes protected_branches_ruleset_created (boolean) for plan-phase assertions.
Run tests with:
terraform testTest Coverage:
- ✅ Preset application (default, production-service, library, staging, experimental, documentation)
- ✅ Name format with and without prefix
- ✅ Explicit name field for renaming
- ✅ Preset overrides (approvals, visibility, checks)
- ✅ workspace injection into properties
- ✅ Multiple repositories with different presets
- ✅ Topics and properties merging
See the examples/ directory:
examples/simple/: Basic governance with preset usageexamples/complete/: Advanced governance features (overrides, renaming, templates, environments)
For repository submodule examples:
modules/repository/examples/simple/: Basic repository usagemodules/repository/examples/complete/: Advanced repository features
- Terraform >= 1.0
- GitHub Provider = 6.8.1
This project is licensed under the MIT License - see the LICENSE file for details.
- Built with Terraform and the GitHub Provider
- Uses terraform-docs for documentation generation
- Testing with native Terraform Test
Made with ❤️ by Victor Varela
| Name | Version |
|---|---|
| terraform | >= 1.0 |
| github | ~> 6.0 |
| Name | Version |
|---|---|
| github | 6.8.2 |
| Name | Source | Version |
|---|---|---|
| repositories | ./modules/repository | n/a |
| Name | Type |
|---|---|
| github_app.bypass_apps | data source |
| github_organization_teams.all | data source |
| Name | Description | Type | Default | Required |
|---|---|---|---|---|
| github_app_ids | Optional map of app slug -> app installation ID. If empty, fetches apps individually via data source. Used for branch protection bypass actors. | map(number) |
{} |
no |
| github_team_ids | Optional map of team slug -> team ID. If empty, fetches all organization teams via data source. Used for branch protection bypass actors and environment reviewers. | map(number) |
{} |
no |
| github_user_ids | Optional map of user login -> numeric user ID. If empty, repository module resolves IDs individually via data source (less efficient). Used for environment reviewers. Cannot be auto-fetched org-wide as GitHub API returns node IDs (strings) not numeric IDs. | map(number) |
{} |
no |
| organization | GitHub organization name where repositories will be created. | string |
n/a | yes |
| presets | Preset configurations map. Select with repositories[*].preset; falls back to 'default'. | map(object({ |
{ |
no |
| repositories | Map of repositories to create. The map key is a stable identifier (won't trigger recreation). Use 'name' field to rename repositories safely. | map(object({ |
n/a | yes |
| repository_naming | sprintf-style format string for repository names. Use a single '%s' placeholder for the repository key. Example: '%s' (no prefix), 'myorg-%s' (with prefix). | string |
"%s" |
no |
| workspace | workspace/namespace name for logical grouping of repositories. Will be stored as a custom property on each repository. | string |
n/a | yes |
| Name | Description |
|---|---|
| organization | The GitHub organization name. |
| repositories | Map of repository keys to their full output details from the repository module. |
| repository_names | Map of repository keys to their GitHub names. |
| repository_urls | Map of repository keys to their HTML URLs. |
| workspace | The workspace name applied to all repositories. |