One boundary for a GitHub team and everything that hangs off it — the team itself plus its code-review settings, authoritative membership, and repository grants, all wired through a single
team_id. Secure-by-default (closedprivacy, least-privilegemember/pullroles), deeply-typedobjectinputs, andfor_eachcollections keyed on stable strings. Built for integrations/github v6.x.
This composite module provisions a complete GitHub team as one unit:
- 👥 The team (
github_team.this) — name, description, privacy, notifications, optional nesting under a parent team. - ⚙️ Code-review settings (
github_team_settings.settings) — optional review-request auto-assignment (round-robin / load-balance) via the v4 GraphQL API. - 🔑 Authoritative membership (
github_team_membership.members) — afor_eachmap ofusername → role; the module owns the full roster. - 📦 Repository grants (
github_team_repository.repos) — afor_eachmap ofrepository → permission, granting the team access to existing repos.
💡 Why it matters: a team's people, its repo access, and its review automation drift apart when managed in three separate places. This module makes the team's roster and grants declarative and authoritative — what's in the map is what exists, nothing more — so access reviews become a diff, not an investigation.
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- ⭐ Star this repository to help others discover this Terraform module.
- 🤝 Connect with me on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!
flowchart LR
repo["tf-mod-github-repository<br/>(keystone)"]
team["tf-mod-github-team<br/>(THIS)"]
sync["tf-mod-github-team-sync-group-mapping"]
roles["tf-mod-github-organization-roles"]
collab["tf-mod-github-repository-collaborators"]
ruleset["tf-mod-github-repository-ruleset"]
nested["tf-mod-github-team<br/>(nested child)"]
repo -->|"id (repo name)"| team
team -->|"slug / id"| sync
team -->|"id (role-team)"| roles
team -->|"slug (team block)"| collab
team -->|"id (bypass actor)"| ruleset
team -->|"id (parent_team_id)"| nested
style team fill:#8957E5,color:#fff
style repo fill:#24292F,color:#fff
This module consumes repository names from tf-mod-github-repository (id) for its repositories grants and, optionally, a parent team's id from another tf-mod-github-team. It emits id (parent for nested teams, role-team, ruleset bypass actor) and slug (CODEOWNERS, collaborator team blocks, team-sync mappings). See the Cross-Module Contract.
flowchart TD
this["github_team.this<br/>(the team)"]
settings["github_team_settings.settings<br/>(optional singleton)"]
members["github_team_membership.members<br/>(for_each: username → role)"]
repos["github_team_repository.repos<br/>(for_each: repository → permission)"]
this -->|team_id| settings
this -->|team_id| members
this -->|team_id| repos
style this fill:#8957E5,color:#fff
Resource inventory
github_team.this— the primary team resource; everything else references itsid.github_team_settings.settings— optional singleton (created only whenvar.settingsis set) holding code-review notification and review-request delegation.github_team_membership.members— one resource per username; the module'sfor_eachmakes the roster authoritative.github_team_repository.repos— one resource per repository; grants the team a permission level on an already-existing repo.
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
| Provider | integrations/github ~> 6.0 (current 6.12.1) |
⚠️ Source isintegrations/github, never the deprecatedhashicorp/github. Migrate old state withterraform state replace-provider registry.terraform.io/hashicorp/github registry.terraform.io/integrations/github.
ℹ️ Schema notes that bite:
github_team_settingsrelies on the v4 GraphQL API (Stone Crop schema) — the presence of areview_request_delegationblock (even empty) enables delegation.ldap_dnis GitHub Enterprise Server only and silently ignored on github.com / Enterprise Cloud.
tf-mod-github-team/
├── providers.tf # terraform{} + required_providers (integrations/github ~> 6.0)
├── variables.tf # name, parent_team_id, privacy, settings, members, repositories …
├── main.tf # github_team.this + settings / members / repos collections
├── outputs.tf # id, node_id, slug, name, *_ids, settings_team_uid
├── SCOPE.md # design contract: in-scope, consumes/emits, token scopes, prereqs
└── README.md # this file
module "platform_team" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-team?ref=v1.0.0"
name = "Platform Engineering"
description = "Owns the shared platform and infrastructure modules."
members = {
"casey-wood" = { role = "maintainer" }
"octocat" = {} # defaults to role = "member"
}
repositories = {
(module.platform_api.id) = { permission = "push" } # repo name from tf-mod-github-repository
}
}💡
repositoriesis keyed by repository name, which is exactly theidoutput oftf-mod-github-repository— wire them directly.
| Input | Type | Source |
|---|---|---|
repositories[*] (key) |
string (repo name) |
tf-mod-github-repository (id) |
parent_team_id |
string (team id or slug) |
another tf-mod-github-team (id) — nested teams |
| Output | Description | Consumed by |
|---|---|---|
id |
Numeric team ID | tf-mod-github-team (parent_team_id), tf-mod-github-organization-roles (role-team), tf-mod-github-repository-ruleset (bypass actor) |
node_id |
GraphQL global node ID | GraphQL automation |
slug |
URL-safe team slug | tf-mod-github-repository-collaborators (team blocks), CODEOWNERS, tf-mod-github-team-sync-group-mapping |
name |
The team's full name | Reporting / display |
members_count |
Number of members managed | Reporting |
membership_ids |
Map: username → github_team_membership id |
Downstream auditing |
repository_ids |
Map: repo → github_team_repository id |
Downstream auditing |
settings_team_uid |
Team node ID from settings (null when unmanaged) | GraphQL automation |
1️⃣ Minimal — just the team
module "team" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-team?ref=v1.0.0"
name = "Security Champions"
}Creates a single closed team with no members, grants, or settings.
2️⃣ Description + notifications off
module "team" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-team?ref=v1.0.0"
name = "On-Call Rotation"
description = "Pager rotation — high-volume, mentions muted."
notification_setting = "notifications_disabled"
}3️⃣ Authoritative membership (member vs maintainer)
module "team" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-team?ref=v1.0.0"
name = "Lending Core"
members = {
"casey-wood" = { role = "maintainer" }
"octocat" = { role = "member" }
"hubot" = {} # role defaults to "member"
}
}🔒 The roster is authoritative — a user removed from this map is removed from the team on the next apply. Do not also manage these members elsewhere.
4️⃣ Nested team under a parent
module "parent" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-team?ref=v1.0.0"
name = "Engineering"
}
module "child" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-team?ref=v1.0.0"
name = "Engineering — Backend"
parent_team_id = module.parent.id # numeric id or slug
privacy = "closed" # required for nesting
}
⚠️ Asecretteam cannot be nested. Keepprivacy = "closed"(the default) wheneverparent_team_idis set.
5️⃣ Repository grants (least privilege)
module "team" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-team?ref=v1.0.0"
name = "Docs Maintainers"
repositories = {
"developer-portal" = { permission = "push" }
"api-reference" = {} # permission defaults to "pull" (read-only)
}
}Built-in roles: pull, triage, push, maintain, admin. A custom repository role name (Enterprise Cloud) is also accepted.
6️⃣ Code-review auto-assignment (round-robin)
module "team" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-team?ref=v1.0.0"
name = "Reviewers"
settings = {
notify = true
review_request_delegation = {
algorithm = "ROUND_ROBIN"
member_count = 2
}
}
}💡 The mere presence of
review_request_delegationenables delegation. Omit the block to keep settings without auto-assignment; setsettings = nullto manage no settings at all.
7️⃣ Load-balanced review assignment
module "team" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-team?ref=v1.0.0"
name = "Frontend Guild"
settings = {
review_request_delegation = {
algorithm = "LOAD_BALANCE"
member_count = 1
}
}
}LOAD_BALANCE spreads requests by each member's outstanding review count rather than strict rotation.
8️⃣ Members + repos + settings combined
module "team" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-team?ref=v1.0.0"
name = "Payments"
description = "Owns the payments service and its release tooling."
members = {
"casey-wood" = { role = "maintainer" }
"octocat" = { role = "member" }
}
repositories = {
"payments-service" = { permission = "maintain" }
"payments-infra" = { permission = "push" }
}
settings = {
notify = true
review_request_delegation = { algorithm = "ROUND_ROBIN", member_count = 1 }
}
}9️⃣ Repository wired from tf-mod-github-repository
module "platform_api" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-repository?ref=v1.0.0"
name = "platform-api"
visibility = "private"
}
module "team" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-team?ref=v1.0.0"
name = "Platform"
repositories = {
(module.platform_api.id) = { permission = "push" } # id == repo name
}
}💡
module.platform_api.idis the repo name — use it directly as therepositoriesmap key.
🔟 Secure / hardened variant
module "team" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-team?ref=v1.0.0"
name = "Prod Release Approvers"
description = "Tightly scoped; least privilege everywhere."
privacy = "closed" # discoverable, never secret-but-orphaned
# Authoritative roster — exactly these people, one maintainer.
members = {
"casey-wood" = { role = "maintainer" }
"release-bot" = { role = "member" }
}
# Read-only by default; widen per-repo only where required.
repositories = {
"prod-config" = { permission = "pull" }
"release-tools" = { permission = "push" }
}
}🔒 Defaults already enforce least privilege:
privacy = "closed",role = "member",permission = "pull". Every broader grant above is an explicit opt-in.
1️⃣1️⃣ for_each at scale — fleet of teams from a map
locals {
teams = {
"data-platform" = {
description = "Owns the lakehouse and ETL."
members = { "casey-wood" = { role = "maintainer" }, "octocat" = {} }
repos = { "lakehouse" = { permission = "maintain" } }
}
"ml-platform" = {
description = "Owns model training infra."
members = { "hubot" = { role = "maintainer" } }
repos = { "ml-infra" = { permission = "push" } }
}
}
}
module "team" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-team?ref=v1.0.0"
for_each = local.teams
name = each.key
description = each.value.description
members = each.value.members
repositories = each.value.repos
}
⚠️ Each team, member, and repo grant is one API call. A largefor_eachcan trip secondary rate limits — see Troubleshooting.
1️⃣2️⃣ Team as a ruleset bypass actor
module "admins" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-team?ref=v1.0.0"
name = "Repo Admins"
}
# Pass module.admins.id into a ruleset's bypass_actors (actor_type = "Team").
module "ruleset" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-repository-ruleset?ref=v1.0.0"
repository = module.platform_api.id
bypass_team_id = module.admins.id
# …ruleset config…
}💡 Rulesets reference teams by numeric
idas a bypass actor; CODEOWNERS and collaborator blocks reference them byslug. The module emits both.
1️⃣3️⃣ Team-sync group mapping (consumes slug)
module "team" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-team?ref=v1.0.0"
name = "SSO-Backed Engineers"
}
module "team_sync" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-team-sync-group-mapping?ref=v1.0.0"
team_slug = module.team.slug # IdP-backed membership
# …group mappings…
}
⚠️ When team-sync owns membership, do not also populatemembershere — pick one source of truth to avoid a fight on every apply.
🏗️ 1️⃣4️⃣ End-to-end composition (full suite, outputs → inputs)
# 1) Keystone repository
module "platform_api" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-repository?ref=v1.0.0"
name = "platform-api"
visibility = "private"
vulnerability_alerts = true
}
# 2) Parent team
module "engineering" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-team?ref=v1.0.0"
name = "Engineering"
}
# 3) Nested team that owns the repo, with review automation
module "platform_team" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-team?ref=v1.0.0"
name = "Platform"
parent_team_id = module.engineering.id
privacy = "closed"
members = {
"casey-wood" = { role = "maintainer" }
"octocat" = { role = "member" }
}
repositories = {
(module.platform_api.id) = { permission = "maintain" }
}
settings = {
notify = true
review_request_delegation = { algorithm = "ROUND_ROBIN", member_count = 1 }
}
}
# 4) Protect the default branch; the team bypasses
module "ruleset" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-repository-ruleset?ref=v1.0.0"
repository = module.platform_api.id
enforcement = "active"
bypass_team_id = module.platform_team.id
}The repository's id feeds both the team's repositories grant and the ruleset's repository; the team's id feeds the parent relationship and the ruleset's bypass actor. One graph, wired entirely through outputs → inputs.
Identity & hierarchy
name(required) — the team name; the slug is recomputed from it.parent_team_id— parent teamid/slugfor nesting (null = root team).
Team configuration
description,privacy(closed/secret),notification_setting,ldap_dn(Enterprise Server only).
Collections & settings
settings— optional code-review settings object (null disables the resource).members—map(object)ofusername → role; authoritative.repositories—map(object)ofrepository → permission.
Full object schemas
settings = object({
notify = optional(bool, false)
review_request_delegation = optional(object({
algorithm = optional(string, "ROUND_ROBIN") # ROUND_ROBIN | LOAD_BALANCE
member_count = optional(number, 1)
}))
}) # default: null
members = map(object({
role = optional(string, "member") # member | maintainer
})) # default: {}
repositories = map(object({
permission = optional(string, "pull") # pull | triage | push | maintain | admin | <custom role>
})) # default: {}| Output | Description | Notes |
|---|---|---|
id |
Numeric team ID | Primary cross-module reference |
node_id |
GraphQL global node ID | |
slug |
URL-safe team slug | What CODEOWNERS / collaborators expect |
name |
Team's full name | |
members_count |
Number of managed members | |
membership_ids |
Map: username → membership id | Keyed like var.members |
repository_ids |
Map: repo → grant id | Keyed like var.repositories |
settings_team_uid |
Team node id from settings | null when settings unmanaged |
ℹ️ No
sensitiveoutputs — a team exposes no secret material.
idvsnode_idvsslug.idis the numeric REST identifier (parent teams, role-teams, ruleset bypass actors consume it).node_idis the opaque GraphQL global id.slugis the URL-safe handle — and it is what CODEOWNERS, collaborator team blocks, and team-sync mappings reference, notid. The module emits all three so callers never guess.parent_team_idis in-place, not ForceNew. Re-parenting a team updates it in place — but it changes inherited access, so treat it as a security-relevant change in review.secretprivacy excludes nesting. Asecretteam cannot have a parent. defaults toclosed(provider default issecret) precisely so teams stay nestable and discoverable.- Authoritative vs additive membership.
github_team_membershipis additive per user at the provider level, but the module drives it withfor_eachover the wholemembersmap — so the roster is authoritative: anyone not in the map is removed. Don't mix this with externally-managed membership or team-sync on the same team. - Settings ride the v4 GraphQL API.
github_team_settingsuses the Stone Crop GraphQL schema. The presence of areview_request_delegationblock (even empty) enables delegation — there is no separate boolean. - Rulesets vs branch protection. This module governs who is on a team and what they can reach, not branch rules. Branch enforcement lives in
tf-mod-github-repository-ruleset(preferred) or the legacygithub_branch_protection; a team here is typically wired in as a bypass actor byid. - Eventual consistency & secondary rate limits. The GitHub REST API is eventually consistent — a freshly created team/member may not be immediately visible, and bulk
for_eachcollections issue one call per entry, which can trip secondary rate limits. See Troubleshooting. - No tags, no timeouts, no ARNs. GitHub has none of these; the module exposes
id/node_id/sluginstead.
- 🔒 Least privilege by default —
role = "member",permission = "pull". Broader access is always an explicit opt-in. - 👁️ Discoverable, not secret —
privacy = "closed"by default so teams participate in the hierarchy and access reviews. - 📜 Authoritative collections — the
membersandrepositoriesmaps are the desired state; drift is removed, not accumulated. - 🧱 One owner per relationship — team↔repo grants live here (the team's view); repo-centric grants live in
tf-mod-github-repository-collaborators(the repo's view). Pick one per relationship. - 🔑 Auth is a provider concern — no
owner/token/app_authvariables ever appear in this module.
terraform init -backend=false
terraform fmt -check
terraform validate
terraform plan
terraform apply
terraform output
⚠️ Always pin the module source to a tag —?ref=v1.0.0— never a branch. Branches move; releases don't.
The offline proof gate (no live org required):
terraform fmt -check # zero formatting differences
terraform validate # zero errors
tflint # core rules — no dedicated GitHub ruleset existsℹ️
plan/applyrequire a configuredintegrations/githubprovider (PAT / GitHub App) withadmin:org; keep them out of the offline gate.
Outputs:
id = "7654321"
node_id = "T_kwDOABCD1234"
slug = "platform"
name = "Platform"
members_count = 2
membership_ids = {
"casey-wood" = "7654321:casey-wood"
"octocat" = "7654321:octocat"
}
repository_ids = {
"platform-api" = "7654321:platform-api"
}
settings_team_uid = "T_kwDOABCD1234"
| Symptom | Cause | Resolution |
|---|---|---|
403 / Resource not accessible on team create |
Identity lacks org-owner / team-maintainer rights | Use a PAT with admin:org (or a GitHub App with Members: read/write); the identity must be an org owner. |
422 re-parenting fails / secret team rejected for nesting |
privacy = "secret" on a team with a parent_team_id |
Set privacy = "closed" (the default) whenever nesting. |
| Member keeps reappearing after removal | Membership also managed by team-sync or another module | Choose one owner. If SSO/IdP owns it, leave members = {} and use tf-mod-github-team-sync-group-mapping. |
repository... not found on a grant |
Repo doesn't exist yet (grants don't create repos) | Create it via tf-mod-github-repository first and wire its id as the map key. |
You have exceeded a secondary rate limit |
Large for_each issuing many calls in a burst |
Apply with reduced -parallelism, split into smaller applies, or retry after a backoff. |
github_team_settings errors / no effect |
v4 GraphQL not available, or delegation block omitted | Confirm GraphQL access; remember the presence of review_request_delegation is what enables it. |
ldap_dn ignored |
On github.com / Enterprise Cloud | ldap_dn is Enterprise Server only; leave it null elsewhere. |
tf-mod-github-repository— the keystone that emits repo names consumed heretf-mod-github-repository-ruleset— branch governance; consumes the teamidas a bypass actortf-mod-github-team-sync-group-mapping— IdP-backed team membership; consumes the teamslugtf-mod-github-repository-collaborators— the repo-centric counterpart torepositoriesgrants- integrations/github provider reference —
github_team,github_team_settings,github_team_membership,github_team_repository
💙 "Infrastructure as Code should be standardized, consistent, and secure."