Manages a single organization-scoped GitHub Actions runner group — visibility scoping, selected-repository access, public-repo gating, and workflow restriction, all secure-by-default. Built for integrations/github v6.x.
This module wraps the single github_actions_runner_group resource — the org-level boundary that decides which repositories (and optionally which workflows) are allowed to dispatch jobs to a set of self-hosted runners.
- 🏷️ Names an organization runner group (
name, unique within the org). - 🔐 Scopes visibility to
selectedrepos (default, least-privilege) orallrepos. - 🎯 Allows-lists repositories by numeric
repo_idwhenvisibility = "selected". - 🚫 Gates public repositories off by default (
allows_public_repositories = false). - 🧱 Restricts workflows to an explicit allow-list (
restricted_to_workflows+selected_workflows). - 📤 Emits the runner group
idplus runtime attributes (default,inherited, API URLs,etag) for downstream wiring.
💡 Why it matters: Self-hosted runners execute workflow code on infrastructure you own. A runner group is the only org-level control that keeps an untrusted or public repo from scheduling jobs on those machines — getting its defaults right is a supply-chain security decision, not a convenience.
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!
This module is the access-scoping boundary between the keystone repository module and any billed runner compute — it consumes repository IDs and hands its own id to the modules that need to place runners inside the boundary.
flowchart LR
repo["terraform-github-repository<br/>(keystone)"]
runnergroup["terraform-github-actions-runner-group<br/>(THIS MODULE)"]
hostedrunner["terraform-github-actions-hosted-runner"]
actorg["terraform-github-actions-organization"]
repo -->|"repo_id"| runnergroup
runnergroup -->|"id (runner_group_id)"| hostedrunner
runnergroup -->|"id"| actorg
style runnergroup fill:#8957E5,color:#fff
style repo fill:#24292F,color:#fff
This module consumes selected_repository_ids (numeric repo_ids from terraform-github-repository); it emits id — consumed by terraform-github-actions-hosted-runner (runner_group_id) and referenced by terraform-github-actions-organization / runner tooling — see the Typical wiring section.
A single, flat organization-scoped runner group — every argument is a scalar or list attribute, so there are no nested or dynamic blocks to render.
flowchart TD
this["github_actions_runner_group.this<br/>(keystone)<br/>flat org-scoped access boundary (visibility, repo/workflow allow-lists)"]
style this fill:#8957E5,color:#fff
terraform-github-actions-runner-group/
├── providers.tf # terraform >= 1.12.0; integrations/github ~> 6.0
├── variables.tf # name, visibility, selected_repository_ids, flags, workflows
├── main.tf # github_actions_runner_group.this (flat, no dynamic blocks)
├── outputs.tf # id + name/visibility/default/inherited/URLs/etag
├── SCOPE.md # scopes, prerequisites, emits, gotchas
└── README.md # this file
module "ci_runner_group" {
source = "git::https://github.com/microsoftexpert/terraform-github-actions-runner-group?ref=v1.0.0"
name = "linux-self-hosted"
# Least-privilege default: only the listed repos may use these runners.
visibility = "selected"
selected_repository_ids = [module.repository.repo_id]
}ℹ️ The target organization (
owner/GITHUB_OWNER) and authentication are configured on the caller's provider block, never as module variables.
Every output in outputs.tf gets a row. The runner group id is the primary cross-module reference; selected_repository_ids consumes repo IDs from the keystone repository module.
| Output | Type | Typically consumed by |
|---|---|---|
id |
string (numeric runner group ID) | terraform-github-actions-organization / runner registration tooling; terraform import target |
name |
string | human reference; runner registration scripts |
visibility |
string (all | selected) |
governance / drift reporting |
default |
bool | governance checks (is this the org default group?) |
inherited |
bool | governance checks (enterprise-inherited group?) |
allows_public_repositories |
bool | security posture reporting |
restricted_to_workflows |
bool | security posture reporting |
selected_workflows |
list(string) | audit of allow-listed workflows |
selected_repository_ids |
list(number) | audit of repos with runner access |
runners_url |
string | runner inventory tooling (GitHub API) |
selected_repositories_url |
string | repo-access auditing (GitHub API) |
etag |
string | change-detection / caching |
⚠️ selected_repository_idstakes the numericrepo_id(database ID), not the repository name. Wire it frommodule.repository.repo_id, nevermodule.repository.id.
- The
idis a numeric runner group ID — not a name or node id.github_actions_runner_group.this.idis the runner group's integer database ID assigned by GitHub. This is also the value you pass toterraform import github_actions_runner_group.this <id>. - No
node_id/repo_id/full_name/slug/html_url. Those attributes belong to repository- and team-shaped resources. A runner group is an org-scoped Actions config object and the provider exposes none of them, so the module deliberately does not emit them — over-emitting a nonexistent attribute is a plan error, not a courtesy. Useidfor references andrunners_url/selected_repositories_urlfor API-level lookups. nameis the natural identity but is mutable. Renaming the group updates it in place (no ForceNew); GitHub keys the resource on the numeric ID, so a rename does not replace the group or its runner registrations."private"visibility is rejected at plan time. GitHub documents aprivatevalue, but the GitHub API does not currently support it and it fails at apply. The module'svalidation {}block rejects it early — use"selected"to scope access narrowly.- Conditional arguments are passed unconditionally and ignored by the API when out of scope.
selected_repository_idsis honored only whenvisibility = "selected";selected_workflowsonly whenrestricted_to_workflows = true. Both default to[], somain.tfpasses them directly with nonullguards and nodynamicblocks — the resource is a flat, total projection of the typed inputs. restricted_to_workflows = truewith an emptyselected_workflowsallows nothing. The flag is an allow-list switch; turning it on without populating the list blocks every workflow on the group.- Org-scope, not enterprise-scope. This module manages
github_actions_runner_group(organization). Enterprise-level groups are a different resource (github_enterprise_actions_runner_group) and are out of scope. Theinheritedoutput reports whether this org group was pushed down from the enterprise level. - No secrets handled here. Unlike Actions secret modules, a runner group has no
plaintext_value/encrypted_valuesurface — nothing in this resource issensitive. (Runner registration tokens are issued out-of-band by the runner installer, not by Terraform.) - Rulesets vs branch protection — not applicable. This resource governs runner access, not branches, so the rulesets-over-branch-protection preference does not bite here. The relevant control plane is
visibility+selected_repository_ids.
1 · Minimal (secure default)
module "rg_minimal" {
source = "git::https://github.com/microsoftexpert/terraform-github-actions-runner-group?ref=v1.0.0"
name = "default-self-hosted"
# visibility defaults to "selected" with an empty allow-list — no repo can
# use the group until you add IDs. Locked-down by default.
}2 · Selected repositories (explicit allow-list)
module "rg_selected" {
source = "git::https://github.com/microsoftexpert/terraform-github-actions-runner-group?ref=v1.0.0"
name = "linux-builders"
visibility = "selected"
selected_repository_ids = [101234567, 109876543] # numeric repo_id values
}3 · Visible to all repositories in the org
module "rg_all" {
source = "git::https://github.com/microsoftexpert/terraform-github-actions-runner-group?ref=v1.0.0"
name = "org-wide-runners"
visibility = "all"
# selected_repository_ids is ignored when visibility = "all".
}4 · Allow public repositories (explicit opt-in)
module "rg_public" {
source = "git::https://github.com/microsoftexpert/terraform-github-actions-runner-group?ref=v1.0.0"
name = "oss-runners"
visibility = "selected"
selected_repository_ids = [102002002]
allows_public_repositories = true # ⚠️ accepts untrusted external PR workflows
}5 · Restrict to specific workflows
module "rg_workflow_locked" {
source = "git::https://github.com/microsoftexpert/terraform-github-actions-runner-group?ref=v1.0.0"
name = "deploy-only"
visibility = "selected"
selected_repository_ids = [105550001]
restricted_to_workflows = true
selected_workflows = [
"octo-org/infra/.github/workflows/deploy.yml@refs/heads/main",
]
}6 · Multiple allow-listed workflows
module "rg_multi_workflow" {
source = "git::https://github.com/microsoftexpert/terraform-github-actions-runner-group?ref=v1.0.0"
name = "release-pipeline"
visibility = "selected"
selected_repository_ids = [105550001, 105550002]
restricted_to_workflows = true
selected_workflows = [
"octo-org/app/.github/workflows/build.yml@refs/heads/main",
"octo-org/app/.github/workflows/release.yml@refs/tags/v*",
]
}7 · Fully hardened variant
module "rg_hardened" {
source = "git::https://github.com/microsoftexpert/terraform-github-actions-runner-group?ref=v1.0.0"
name = "prod-deploy-runners"
visibility = "selected" # narrow exposure
selected_repository_ids = [105550001] # one repo only
allows_public_repositories = false # no untrusted public PRs
restricted_to_workflows = true # allow-list workflows
selected_workflows = [
"octo-org/prod-infra/.github/workflows/apply.yml@refs/heads/main",
]
}8 · Cross-module wiring with the keystone repository
module "repository" {
source = "git::https://github.com/microsoftexpert/terraform-github-repository?ref=v1.0.0"
name = "payments-service"
}
module "rg_wired" {
source = "git::https://github.com/microsoftexpert/terraform-github-actions-runner-group?ref=v1.0.0"
name = "payments-runners"
visibility = "selected"
selected_repository_ids = [module.repository.repo_id] # numeric repo_id, not id
}9 · Wiring several repositories from a list of modules
module "rg_team_group" {
source = "git::https://github.com/microsoftexpert/terraform-github-actions-runner-group?ref=v1.0.0"
name = "platform-team-runners"
visibility = "selected"
selected_repository_ids = [
module.repo_api.repo_id,
module.repo_web.repo_id,
module.repo_jobs.repo_id,
]
}10 · `for_each` at scale from a `map(object)`
locals {
runner_groups = {
linux = {
name = "linux-self-hosted"
visibility = "selected"
selected_repository_ids = [105550001, 105550002]
}
windows = {
name = "windows-self-hosted"
visibility = "selected"
selected_repository_ids = [105550003]
}
org_wide = {
name = "org-wide"
visibility = "all"
selected_repository_ids = []
}
}
}
module "runner_groups" {
source = "git::https://github.com/microsoftexpert/terraform-github-actions-runner-group?ref=v1.0.0"
for_each = local.runner_groups
name = each.value.name
visibility = each.value.visibility
selected_repository_ids = each.value.selected_repository_ids
}11 · `for_each` with full per-group config
locals {
groups = {
deploy = {
name = "deploy-runners"
visibility = "selected"
selected_repository_ids = [105550001]
allows_public_repositories = false
restricted_to_workflows = true
selected_workflows = ["octo-org/infra/.github/workflows/deploy.yml@refs/heads/main"]
}
oss = {
name = "oss-runners"
visibility = "selected"
selected_repository_ids = [105550009]
allows_public_repositories = true
restricted_to_workflows = false
selected_workflows = []
}
}
}
module "runner_groups_full" {
source = "git::https://github.com/microsoftexpert/terraform-github-actions-runner-group?ref=v1.0.0"
for_each = local.groups
name = each.value.name
visibility = each.value.visibility
selected_repository_ids = each.value.selected_repository_ids
allows_public_repositories = each.value.allows_public_repositories
restricted_to_workflows = each.value.restricted_to_workflows
selected_workflows = each.value.selected_workflows
}12 · Wiring runner IDs from a `for_each` repository module
module "repos" {
source = "git::https://github.com/microsoftexpert/terraform-github-repository?ref=v1.0.0"
for_each = toset(["api", "web", "jobs"])
name = "service-${each.key}"
}
module "rg_from_repos" {
source = "git::https://github.com/microsoftexpert/terraform-github-actions-runner-group?ref=v1.0.0"
name = "all-services"
visibility = "selected"
selected_repository_ids = [for r in module.repos : r.repo_id]
}🔒 Default posture reminder: with no overrides, a group is
visibility = "selected"with an empty allow-list,allows_public_repositories = false, andrestricted_to_workflows = false. No repository can use it until you add IDs — fail-closed by design.
Identity
name(string, required) — unique runner group name within the org.
Access scoping
visibility(string, default"selected") —"all"or"selected"."private"is rejected (unsupported by the API).selected_repository_ids(list(number), default[]) — numericrepo_ids; honored only whenvisibility = "selected".allows_public_repositories(bool, defaultfalse) — opt-in for public repos.
Workflow restriction
restricted_to_workflows(bool, defaultfalse) — switch the workflow allow-list on.selected_workflows(list(string), default[]) — fully-qualified workflow refs; honored only whenrestricted_to_workflows = true.
| Output | Description |
|---|---|
id |
Runner group numeric ID (primary cross-module reference; terraform import target). |
name |
Name of the runner group. |
visibility |
Effective visibility (all or selected). |
default |
Whether this is the org's default runner group. |
inherited |
Whether the group is inherited from the enterprise level. |
allows_public_repositories |
Whether public repos may be added. |
restricted_to_workflows |
Whether the group is restricted to selected_workflows only. |
selected_workflows |
Allow-listed workflows (when restricted_to_workflows = true). |
selected_repository_ids |
Numeric repo IDs with access (when visibility = "selected"). |
runners_url |
GitHub API URL for the group's runners. |
selected_repositories_url |
GitHub API URL for the group's selected repositories. |
etag |
An etag representing the runner group object. |
ℹ️ No output is
sensitive— this resource holds no secret material.
- Fail-closed by default.
visibility = "selected"with an empty allow-list means no repo can reach the runners until explicitly granted. - Least-privilege access surface. Prefer
"selected"+ a tightselected_repository_idslist over"all"; opt intoallows_public_repositoriesonly when an OSS workflow genuinely needs it. - No untrusted public code on owned infra by default.
allows_public_repositories = falsekeeps external-PR workflows off self-hosted runners unless deliberately enabled. - Workflow allow-listing for high-trust groups.
restricted_to_workflowslets a production-deploy group run only its sanctioned workflows. - Auth & org are provider concerns. No
owner/token/app_auth/base_urlvariables — the caller's provider block owns them. - Total projection, no hidden logic. Every input maps 1:1 to a resource argument; no
count, nodynamic, no conditional nulls.
| Scope / Permission | Required for | Notes |
|---|---|---|
Classic PAT — admin:org |
Create / read / update / delete org runner groups | Broad org-admin scope; works for all runner-group operations. |
Classic PAT — manage_runners:org |
Managing self-hosted runners & runner groups | Narrower, dedicated scope — prefer this over admin:org when available (least-privilege). |
| Fine-grained PAT / GitHub App — Organization → Self-hosted runners (Read & write) | All runner-group CRUD | The specific fine-grained permission for this resource. |
| Fine-grained PAT / GitHub App — Organization → Administration (Read) | Resolving org context | Read-level org administration access supports lookups in some provider paths. |
Repository repo_id source |
selected_repository_ids wiring |
The IDs come from the repository module; no extra repo scope is needed on this identity to reference numeric IDs. |
⚠️ This is an organization-admin operation. The provider identity must holdadmin:org(ormanage_runners:org) on a classic PAT, or the Self-hosted runners (read & write) organization permission on a fine-grained PAT / GitHub App. A barerepoor repository-scoped token cannot create or modify org runner groups and will fail with a403. SSO-protected orgs additionally require the PAT to be SSO-authorized for the org.
- Plan / edition: Organization runner groups (beyond the single built-in
Defaultgroup) require GitHub Team or GitHub Enterprise Cloud (or Enterprise Server). On Free orgs only the default group exists, so creating a group with this module will fail. - Actions enabled: GitHub Actions must be enabled for the organization (Org → Settings → Actions). Self-hosted runners must be permitted by org policy.
- Enterprise inheritance: A group with
inherited = trueis managed at the enterprise level (github_enterprise_actions_runner_group), not the org — do not try to manage an inherited group with this module. "private"visibility: Unsupported by the GitHub API; rejected at plan time. Use"selected".- SSO authorization: For orgs with SAML/SSO enforced, the PAT must be authorized for the org or all calls return
403. - API rate limits (bulk
for_each): Each runner group is a separate set of REST calls. Largefor_eachdeployments can hit GitHub's secondary rate limits (abuse-detection throttling on rapid bursts), surfacing as403 You have exceeded a secondary rate limit. Apply in smaller batches or with-parallelismlowered if you manage many groups at once.
terraform init -backend=false
terraform validate
terraform fmt -check
terraform plan
terraform apply
terraform output
⚠️ Always pin the module source to a tag —?ref=v1.0.0— never a branch. Branch refs drift silently between applies.
To adopt an existing group into state:
terraform import github_actions_runner_group.this <runner_group_id>| Symptom | Cause | Resolution |
|---|---|---|
403 on create/update |
Token lacks org-admin scope | Use a PAT with admin:org / manage_runners:org, or a fine-grained token with Self-hosted runners (read & write). |
403 secondary rate limit |
Too many groups created in one burst | Reduce -parallelism, split the for_each into batches, or retry after a short backoff. |
Apply error mentioning private visibility |
visibility = "private" requested |
Not supported by the API; the module rejects it at plan time — use "selected". |
| Group created but no repo can use it | visibility = "selected" with an empty selected_repository_ids |
Add numeric repo_ids (from module.repository.repo_id). |
| Workflows blocked unexpectedly | restricted_to_workflows = true with empty/incorrect selected_workflows |
Populate selected_workflows with fully-qualified refs, or set restricted_to_workflows = false. |
selected_repository_ids shows constant drift |
Repo names passed instead of numeric IDs | Use repo_id (database ID), not id / repo name. |
| Cannot modify an inherited group | Group is enterprise-managed (inherited = true) |
Manage it via github_enterprise_actions_runner_group, not this module. |
404 / creation fails on a Free org |
Plan doesn't support custom runner groups | Upgrade to Team or Enterprise Cloud. |
- The keystone
terraform-github-repositorymodule — emitsrepo_idconsumed here. - The
terraform-github-actions-organizationmodule — org-level Actions policy that complements runner groups. integrations/githubprovider — Actions Runner Group resource reference.- GitHub Docs — Managing access to self-hosted runners using groups.