Skip to content

Repository files navigation

🐙 GitHub Team Terraform Module

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 (closed privacy, least-privilege member / pull roles), deeply-typed object inputs, and for_each collections keyed on stable strings. Built for integrations/github v6.x.

TerraformGitHub providermoduletyperesources


🧩 Overview

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) — a for_each map of username → role; the module owns the full roster.
  • 📦 Repository grants (github_team_repository.repos) — a for_each map of repository → 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.


❤️ Support this project

If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:

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!


🗺️ Where this fits in the family

flowchart LR
repo["terraform-github-repository<br/>(keystone)"]
team["terraform-github-team<br/>(THIS)"]
sync["terraform-github-team-sync-group-mapping"]
roles["terraform-github-organization-roles"]
collab["terraform-github-repository-collaborators"]
ruleset["terraform-github-repository-ruleset"]
nested["terraform-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
Loading

This module consumes repository names from terraform-github-repository (id) for its repositories grants and, optionally, a parent team's id from another terraform-github-team. It emitsid (parent for nested teams, role-team, ruleset bypass actor) and slug (CODEOWNERS, collaborator team blocks, team-sync mappings). See the Cross-Module Contract.


🧬 What this module builds

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
Loading

Resource inventory

  • github_team.this — the primary team resource; everything else references its id.
  • github_team_settings.settings — optional singleton (created only when var.settings is set) holding code-review notification and review-request delegation.
  • github_team_membership.members — one resource per username; the module's for_each makes the roster authoritative.
  • github_team_repository.repos — one resource per repository; grants the team a permission level on an already-existing repo.

✅ Provider / Versions

RequirementValue
Terraform>= 1.12.0
Providerintegrations/github~> 6.0 (current 6.12.1)

⚠️ Source is integrations/github, never the deprecated hashicorp/github. Migrate old state with terraform state replace-provider registry.terraform.io/hashicorp/github registry.terraform.io/integrations/github.

ℹ️ Schema notes that bite: github_team_settings relies on the v4 GraphQL API (Stone Crop schema) — the presence of a review_request_delegation block (even empty) enables delegation. ldap_dn is GitHub Enterprise Server only and silently ignored on github.com / Enterprise Cloud.


📁 Module Structure

terraform-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

⚙️ Quick Start

module"platform_team" {
source="git::https://github.com/microsoftexpert/terraform-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 terraform-github-repository
}
}

💡 repositories is keyed by repository name, which is exactly the id output of terraform-github-repository — wire them directly.


🔌 Cross-Module Contract

Consumes

InputTypeSource
repositories[*] (key)string (repo name)terraform-github-repository (id)
parent_team_idstring (team id or slug)another terraform-github-team (id) — nested teams

Emits

OutputDescriptionConsumed by
idNumeric team IDterraform-github-team (parent_team_id), terraform-github-organization-roles (role-team), terraform-github-repository-ruleset (bypass actor)
node_idGraphQL global node IDGraphQL automation
slugURL-safe team slugterraform-github-repository-collaborators (team blocks), CODEOWNERS, terraform-github-team-sync-group-mapping
nameThe team's full nameReporting / display
members_countNumber of members managedReporting
membership_idsMap: username → github_team_membership idDownstream auditing
repository_idsMap: repo → github_team_repository idDownstream auditing
settings_team_uidTeam node ID from settings (null when unmanaged)GraphQL automation

📚 Example Library

1️⃣ Minimal — just the team
module"team" {
source="git::https://github.com/microsoftexpert/terraform-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/terraform-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/terraform-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/terraform-github-team?ref=v1.0.0"name="Engineering"
}
module"child" {
source="git::https://github.com/microsoftexpert/terraform-github-team?ref=v1.0.0"name="Engineering — Backend"parent_team_id=module.parent.id# numeric id or slugprivacy="closed"# required for nesting
}

⚠️ A secret team cannot be nested. Keep privacy = "closed" (the default) whenever parent_team_id is set.

5️⃣ Repository grants (least privilege)
module"team" {
source="git::https://github.com/microsoftexpert/terraform-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/terraform-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_delegation enables delegation. Omit the block to keep settings without auto-assignment; set settings = null to manage no settings at all.

7️⃣ Load-balanced review assignment
module"team" {
source="git::https://github.com/microsoftexpert/terraform-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/terraform-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 terraform-github-repository
module"platform_api" {
source="git::https://github.com/microsoftexpert/terraform-github-repository?ref=v1.0.0"name="platform-api"visibility="private"
}
module"team" {
source="git::https://github.com/microsoftexpert/terraform-github-team?ref=v1.0.0"name="Platform"repositories={
(module.platform_api.id) = { permission ="push" } # id == repo name
}
}

💡 module.platform_api.id is the repo name — use it directly as the repositories map key.

🔟 Secure / hardened variant
module"team" {
source="git::https://github.com/microsoftexpert/terraform-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/terraform-github-team?ref=v1.0.0"for_each=local.teamsname=each.keydescription=each.value.descriptionmembers=each.value.membersrepositories=each.value.repos
}

⚠️ Each team, member, and repo grant is one API call. A large for_each can trip secondary rate limits — see Troubleshooting.

1️⃣2️⃣ Team as a ruleset bypass actor
module"admins" {
source="git::https://github.com/microsoftexpert/terraform-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/terraform-github-repository-ruleset?ref=v1.0.0"repository=module.platform_api.idbypass_team_id=module.admins.id# …ruleset config…
}

💡 Rulesets reference teams by numeric id as a bypass actor; CODEOWNERS and collaborator blocks reference them by slug. The module emits both.

1️⃣3️⃣ Team-sync group mapping (consumes slug)
module"team" {
source="git::https://github.com/microsoftexpert/terraform-github-team?ref=v1.0.0"name="SSO-Backed Engineers"
}
module"team_sync" {
source="git::https://github.com/microsoftexpert/terraform-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 populate members here — pick one source of truth to avoid a fight on every apply.

🏗️ 1️⃣4️⃣ End-to-end composition (full suite, outputs → inputs)
# 1) Keystone repositorymodule"platform_api" {
source="git::https://github.com/microsoftexpert/terraform-github-repository?ref=v1.0.0"name="platform-api"visibility="private"vulnerability_alerts=true
}
# 2) Parent teammodule"engineering" {
source="git::https://github.com/microsoftexpert/terraform-github-team?ref=v1.0.0"name="Engineering"
}
# 3) Nested team that owns the repo, with review automationmodule"platform_team" {
source="git::https://github.com/microsoftexpert/terraform-github-team?ref=v1.0.0"name="Platform"parent_team_id=module.engineering.idprivacy="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 bypassesmodule"ruleset" {
source="git::https://github.com/microsoftexpert/terraform-github-repository-ruleset?ref=v1.0.0"repository=module.platform_api.idenforcement="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.


📥 Inputs

Identity & hierarchy

  • name(required) — the team name; the slug is recomputed from it.
  • parent_team_id — parent team id/slug for 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).
  • membersmap(object) of username → role; authoritative.
  • repositoriesmap(object) of repository → 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: nullmembers=map(object({
role =optional(string, "member") # member | maintainer
})) # default: {}repositories=map(object({
permission =optional(string, "pull") # pull | triage | push | maintain | admin | <custom role>
})) # default: {}

🧾 Outputs

OutputDescriptionNotes
idNumeric team IDPrimary cross-module reference
node_idGraphQL global node ID
slugURL-safe team slugWhat CODEOWNERS / collaborators expect
nameTeam's full name
members_countNumber of managed members
membership_idsMap: username → membership idKeyed like var.members
repository_idsMap: repo → grant idKeyed like var.repositories
settings_team_uidTeam node id from settingsnull when settings unmanaged

ℹ️ No sensitive outputs — a team exposes no secret material.


🧠 Architecture Notes

  • id vs node_id vs slug.id is the numeric REST identifier (parent teams, role-teams, ruleset bypass actors consume it). node_id is the opaque GraphQL global id. slug is 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_id is 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.
  • secret privacy excludes nesting. A secret team cannot have a parent. defaults to closed (provider default is secret) precisely so teams stay nestable and discoverable.
  • Authoritative vs additive membership.github_team_membership is additive per user at the provider level, but the module drives it with for_each over the wholemembers map — 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_settings uses the Stone Crop GraphQL schema. The presence of a review_request_delegation block (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 terraform-github-repository-ruleset (preferred) or the legacy github_branch_protection; a team here is typically wired in as a bypass actor by id.
  • 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_each collections 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 / slug instead.

🧱 Design Principles

  • 🔒 Least privilege by defaultrole = "member", permission = "pull". Broader access is always an explicit opt-in.
  • 👁️ Discoverable, not secretprivacy = "closed" by default so teams participate in the hierarchy and access reviews.
  • 📜 Authoritative collections — the members and repositories maps 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 terraform-github-repository-collaborators (the repo's view). Pick one per relationship.
  • 🔑 Auth is a provider concern — no owner / token / app_auth variables ever appear in this module.

🚀 Runbook

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.


🧪 Testing

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 / apply require a configured integrations/github provider (PAT / GitHub App) with admin:org; keep them out of the offline gate.


💬 Example Output

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"

🔍 Troubleshooting

SymptomCauseResolution
403 / Resource not accessible on team createIdentity lacks org-owner / team-maintainer rightsUse 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 nestingprivacy = "secret" on a team with a parent_team_idSet privacy = "closed" (the default) whenever nesting.
Member keeps reappearing after removalMembership also managed by team-sync or another moduleChoose one owner. If SSO/IdP owns it, leave members = {} and use terraform-github-team-sync-group-mapping.
repository... not found on a grantRepo doesn't exist yet (grants don't create repos)Create it via terraform-github-repository first and wire its id as the map key.
You have exceeded a secondary rate limitLarge for_each issuing many calls in a burstApply with reduced -parallelism, split into smaller applies, or retry after a backoff.
github_team_settings errors / no effectv4 GraphQL not available, or delegation block omittedConfirm GraphQL access; remember the presence of review_request_delegation is what enables it.
ldap_dn ignoredOn github.com / Enterprise Cloudldap_dn is Enterprise Server only; leave it null elsewhere.

🔗 Related Docs

  • terraform-github-repository — the keystone that emits repo names consumed here
  • terraform-github-repository-ruleset — branch governance; consumes the team id as a bypass actor
  • terraform-github-team-sync-group-mapping — IdP-backed team membership; consumes the team slug
  • terraform-github-repository-collaborators — the repo-centric counterpart to repositories grants
  • integrations/github provider reference — github_team, github_team_settings, github_team_membership, github_team_repository

💙 "Infrastructure as Code should be standardized, consistent, and secure."

Releases

Packages

Contributors

Languages