Manages a single GitHub-hosted (larger) Actions runner for the organization — a fully managed, autoscaling VM runner pinned to an image, a machine size, a runner group, and an explicit cost cap, secure-by-default. Built for integrations/github v6.x.
This module wraps the single github_actions_hosted_runner resource — a GitHub-managed virtual machine (a "larger runner") that executes Actions workflows without you operating any infrastructure. GitHub handles provisioning, patching, and scaling; you declare what the runner is and how far it may scale.
- 🖥️ Provisions a GitHub-hosted larger runner — a managed VM, no servers to maintain.
- 🏷️ Names it (the
runs-ontarget) and assigns it to a runner group (runner_group_id). - 💿 Pins the image (
image— FORCENEW) and the machine size (size— mutable, scales in place). - 💲 Caps cost with
maximum_runners(default 1 — a deliberately small, never-unbounded cap). - 🌐 Optional static egress IP (
public_ip_enabled, defaultfalse; Enterprise/quota-gated). - 🛠️ Supports custom images (
image_version) for teams building their own runner images. - 📤 Emits
idplus resolved runtime detail (platform,status,machine_size_details,public_ips) for wiring and capacity/billing reporting.
💡 Why it matters: GitHub-hosted larger runners are a billed, per-minute resource. The small default cap (
maximum_runners = 1) and static-IP-off-by-default posture mean an empty call cannot quietly run up Actions spend or burn a scarce static-IP quota — you raise the limits deliberately, in code review, when you mean to.
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 billed compute that joins a runner group — it consumes the group's numeric id and is otherwise a leaf in the dependency graph: nothing in this suite consumes a hosted runner's outputs as an input.
flowchart LR
runnergroup["tf-mod-github-actions-runner-group<br/>(access boundary)"]
hostedrunner["tf-mod-github-actions-hosted-runner<br/>(THIS MODULE)"]
runnergroup -->|"id (runner_group_id)"| hostedrunner
style hostedrunner fill:#8957E5,color:#fff
style runnergroup fill:#24292F,color:#fff
This module consumes runner_group_id (from tf-mod-github-actions-runner-group's id output); it emits id, platform, status, and machine_size_details for capacity/billing reporting — see the Typical wiring section. No other module in this suite wires from its outputs.
A single GitHub-hosted larger runner, with its one required image block rendered as a static (always-present) block.
flowchart TD
this["github_actions_hosted_runner.this<br/>(keystone)<br/>managed VM runner + required image block (FORCENEW)"]
style this fill:#8957E5,color:#fff
tf-mod-github-actions-hosted-runner/
├── providers.tf # terraform >= 1.12.0; integrations/github ~> 6.0 (no provider block)
├── variables.tf # name, runner_group_id, image, size, maximum_runners, public_ip_enabled, image_version
├── main.tf # github_actions_hosted_runner.this (one nested image block; thin total renderer)
├── outputs.tf # id + name/runner_group_id/size/platform/status/public_ips/machine_size_details/…
├── SCOPE.md # resource managed, emits, token scopes, prerequisites, gotchas
└── README.md # this file
module "ci_hosted_runner" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-actions-hosted-runner?ref=v1.0.0"
name = "linux-x64-4core"
runner_group_id = module.actions_runner_group.id
image = {
id = "2306" # GitHub-owned Ubuntu 24.04 (numeric id — list via the org images API)
}
size = "4-core"
# maximum_runners defaults to 1 (small cost cap); public_ip_enabled defaults to false.
}ℹ️ The target organization (
owner/GITHUB_OWNER) and authentication are configured on the caller's provider block, never as module variables. This resource is organization-only and requires an organization-owner identity.
Every output in outputs.tf gets a row. The hosted-runner id is the primary cross-module reference; runner_group_id is consumed from the runner-group module.
| Output | Type | Typically consumed by |
|---|---|---|
id |
string (numeric runner ID) | composition / audit; terraform import target |
name |
string | workflow runs-on labels; reporting |
runner_group_id |
number | audit / cross-reference to tf-mod-github-actions-runner-group |
size |
string | capacity / billing reporting |
maximum_runners |
number | cost-governance reporting (the resolved cap) |
platform |
string (linux-x64 | win-x64 …) |
reporting / workflow targeting |
status |
string (Ready | Provisioning …) |
operational health checks |
public_ip_enabled |
bool | network-posture reporting |
public_ips |
list(object) | firewall / IP allow-list automation |
image_id |
string | image-inventory reporting |
image_size_gb |
number | capacity reporting |
machine_size_details |
list(object) | capacity / cost reporting (cpu/memory/storage) |
last_active_on |
string (RFC3339) | idle-runner / utilization reporting |
⚠️ runner_group_idtakes the numeric runner-group ID. Wire it frommodule.actions_runner_group.id(the runner-group module's primary output), or use the org default group id1.
idis the numeric hosted-runner ID — not a name or node id.github_actions_hosted_runner.this.idis the integer ID GitHub assigns. It is also the value you pass toterraform import github_actions_hosted_runner.this <id>.- No
node_id/full_name/slug/html_url— and noarn/object_id. Those belong to repository/team-shaped resources; a hosted runner is an org-scoped Actions object and the provider exposes none of them. The module deliberately does not emit them — over-emitting a nonexistent attribute is a plan error, not a courtesy. imageis immutable (FORCENEW) —sizeis not. GitHub cannot re-image a runner in place, so any change toimage.idorimage.sourcedestroys and recreates the runner: the old VM is torn down (any in-flight job on it is lost) and a fresh one is provisioned, which takes several minutes. By contrastsizeis mutable — it scales the runner up or down in place with no replacement. Plan image changes during a maintenance window.
🔎 This corrects a common assumption:
sizeis not ForceNew, andplatformis not an input at all.
platformis computed, not configured. You choose the OS/arch implicitly via theimageyou select;platform(linux-x64,win-x64, …) is reported back by GitHub as an output. There is noplatforminput or validation.maximum_runnersis the cost lever, and it is never unbounded. It defaults to1— the smallest useful cap — so the empty call cannot stand up a costly fleet. Raise it explicitly to the concurrency you need; jobs beyond the cap queue rather than spawning more (billed) runners.- Static egress IP is gated.
public_ip_enableddefaults tofalse. Static IPs are subject to per-account limits and are an Enterprise/quota-gated feature — enabling it can failapplyon orgs without the entitlement. The assigned ranges surface in thepublic_ipsoutput (populated only when enabled). Check limits viaGET /orgs/{org}/actions/hosted-runners/limits. - Image IDs are numeric, not friendly names. Use
"2306"(Ubuntu 24.04), not"ubuntu-latest". List what is available to your org viaGET /orgs/{org}/actions/hosted-runners/images/github-owned; list sizes viaGET /orgs/{org}/actions/hosted-runners/machine-sizes.image.sourceis one ofgithub/partner/custom;image_versionapplies only tocustomimages (the module validates this). - Group vs runner — a deliberate split from
tf-mod-github-actions-runner-group. The runner group is the access-scoping boundary (which repos/workflows may dispatch jobs); this module is the billed compute that joins a group. They are separate concerns with separate billing impact, so they are separate modules. Wire them:runner_group_id = module.actions_runner_group.id. - No
tags, notimeoutssurface. GitHub has no tagging concept, and this module follows the convention of not surfacing atimeoutsblock. (For completeness: the underlying provider does expose atimeouts { delete }with a 10-minute default; deletion of a hosted runner is asynchronous and the provider polls for that long regardless. Ask the maintainer if you need that delete timeout made configurable.) - No secrets handled here. A hosted runner has no
plaintext_value/encrypted_valuesurface — nothing in this resource issensitive.
ℹ️ GitHub-owned image IDs are numeric and environment-specific —
"2306"is Ubuntu 24.04 at time of writing. Always confirm the IDs and sizes available to your org via the images / machine-sizes APIs. Placeholders below marked<…>must be replaced with real IDs.
1 · Minimal (one Linux runner, default group, small cap)
module "hr_minimal" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-actions-hosted-runner?ref=v1.0.0"
name = "linux-default"
runner_group_id = 1 # the org's built-in Default runner group
image = { id = "2306" } # source defaults to "github"
size = "2-core"
# maximum_runners = 1 (default), public_ip_enabled = false (default)
}2 · Placed in a runner group wired from the runner-group module
module "actions_runner_group" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-actions-runner-group?ref=v1.0.0"
name = "linux-builders"
visibility = "selected"
selected_repository_ids = [module.repository.repo_id]
}
module "hr_in_group" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-actions-hosted-runner?ref=v1.0.0"
name = "linux-builder-4core"
runner_group_id = module.actions_runner_group.id # numeric group id
image = { id = "2306" }
size = "4-core"
}3 · Larger machine size
module "hr_large" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-actions-hosted-runner?ref=v1.0.0"
name = "linux-16core-build"
runner_group_id = module.actions_runner_group.id
image = { id = "2306" }
size = "16-core" # mutable — can be changed later to scale in place
maximum_runners = 3
}4 · Windows runner variant
module "hr_windows" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-actions-hosted-runner?ref=v1.0.0"
name = "win-x64-8core"
runner_group_id = module.actions_runner_group.id
image = {
id = "<windows-server-image-id>" # look up via the org images API
source = "github"
}
size = "8-core"
# platform will report "win-x64" in the outputs (computed from the image).
}5 · Static egress IP (⚠️ Enterprise / quota-gated)
module "hr_static_ip" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-actions-hosted-runner?ref=v1.0.0"
name = "egress-pinned-runner"
runner_group_id = module.actions_runner_group.id
image = { id = "2306" }
size = "4-core"
public_ip_enabled = true # ⚠️ consumes a static-IP quota; may fail apply without the entitlement
# Assigned ranges appear in the `public_ips` output for firewall allow-listing.
}6 · Higher concurrency (raised cost cap)
module "hr_concurrent" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-actions-hosted-runner?ref=v1.0.0"
name = "ci-pool-linux"
runner_group_id = module.actions_runner_group.id
image = { id = "2306" }
size = "4-core"
maximum_runners = 20 # explicit, reviewed cost decision — never left unbounded
}7 · Custom image with a pinned version
module "hr_custom_image" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-actions-hosted-runner?ref=v1.0.0"
name = "hardened-custom-runner"
runner_group_id = module.actions_runner_group.id
image = {
id = "<custom-image-id>"
source = "custom"
}
image_version = "1.4.0" # valid ONLY when image.source = "custom" (module-validated)
size = "8-core"
}8 · Hardened / secure variant
module "hr_hardened" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-actions-hosted-runner?ref=v1.0.0"
name = "prod-deploy-runner"
runner_group_id = module.prod_runner_group.id # a "selected"-visibility group, tightly scoped
image = { id = "2306", source = "github" }
size = "4-core"
maximum_runners = 2 # small explicit cap
public_ip_enabled = false # no static-IP quota burn unless a control truly needs it
}9 · `for_each` at the MODULE level — a fleet of runner types
locals {
hosted_runners = {
linux_small = { name = "linux-2core", image_id = "2306", size = "2-core", max = 5 }
linux_large = { name = "linux-16core", image_id = "2306", size = "16-core", max = 2 }
windows = { name = "win-8core", image_id = "<windows-image-id>", size = "8-core", max = 2 }
}
}
module "hosted_runners" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-actions-hosted-runner?ref=v1.0.0"
for_each = local.hosted_runners
name = each.value.name
runner_group_id = module.actions_runner_group.id
image = { id = each.value.image_id }
size = each.value.size
maximum_runners = each.value.max
}10 · `for_each` with full per-runner config
locals {
runners = {
build = {
name = "build-linux"
runner_group_id = 1
image = { id = "2306", source = "github" }
size = "8-core"
maximum_runners = 10
public_ip = false
}
egress = {
name = "egress-linux"
runner_group_id = 1
image = { id = "2306", source = "github" }
size = "4-core"
maximum_runners = 2
public_ip = true # ⚠️ quota-gated
}
}
}
module "runner_fleet" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-actions-hosted-runner?ref=v1.0.0"
for_each = local.runners
name = each.value.name
runner_group_id = each.value.runner_group_id
image = each.value.image
size = each.value.size
maximum_runners = each.value.maximum_runners
public_ip_enabled = each.value.public_ip
}11 · End-to-end: runner group + hosted runner, wired by output
module "repository" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-repository?ref=v1.0.0"
name = "payments-service"
}
module "actions_runner_group" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-actions-runner-group?ref=v1.0.0"
name = "payments-runners"
visibility = "selected"
selected_repository_ids = [module.repository.repo_id]
}
module "hosted_runner" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-actions-hosted-runner?ref=v1.0.0"
name = "payments-linux-4core"
runner_group_id = module.actions_runner_group.id # group → runner wiring
image = { id = "2306" }
size = "4-core"
maximum_runners = 4
}12 · Reporting the resolved capacity of a runner
output "runner_capacity" {
value = {
platform = module.hosted_runner.platform # e.g. "linux-x64"
status = module.hosted_runner.status # e.g. "Ready"
machine = module.hosted_runner.machine_size_details # cpu_cores / memory_gb / storage_gb
egress = module.hosted_runner.public_ips # [] unless public_ip_enabled
}
}🔒 Default posture reminder: with no overrides, a runner is capped at
maximum_runners = 1and haspublic_ip_enabled = false. You opt up into cost and network exposure — never the reverse.
Identity
name(string, required) — unique runner name within the org; theruns-ontarget. 1–64 chars[A-Za-z0-9._-].
Placement
runner_group_id(number, required) — numeric runner-group ID. Wire frommodule.actions_runner_group.id, or1for the org default group.
Runner definition
image(object, required, FORCENEW) —{ id = string, source = optional(string, "github") }.source∈github/partner/custom. Changing it replaces the runner.size(string, required, mutable) — machine size, e.g."4-core". Not a fixed enum; validated non-empty. Scales in place.
Autoscaling / cost
maximum_runners(number, default1) — concurrency/cost cap; never unbounded. Must be ≥ 1.
Networking
public_ip_enabled(bool, defaultfalse) — static egress IP; Enterprise/quota-gated.
Custom images
image_version(string, defaultnull) — only valid whenimage.source = "custom"(validated).
| Output | Description |
|---|---|
id |
Hosted-runner numeric ID (primary cross-module reference; terraform import target). |
name |
Runner name (the runs-on label). |
runner_group_id |
Numeric ID of the runner group this runner belongs to. |
size |
Machine size (e.g. 4-core). |
maximum_runners |
Resolved maximum concurrent runners (the autoscaling / cost cap). |
platform |
Provider-resolved platform/OS (e.g. linux-x64, win-x64). |
status |
Current runner status (e.g. Ready, Provisioning). |
public_ip_enabled |
Whether a static public egress IP is enabled. |
public_ips |
Assigned public IP ranges ({ enabled, prefix, length }); populated only when public_ip_enabled = true. |
image_id |
Configured image ID backing the runner. |
image_size_gb |
Provider-resolved image disk size in GB. |
machine_size_details |
Resolved machine spec ({ id, cpu_cores, memory_gb, storage_gb }). |
last_active_on |
Timestamp (RFC3339) when the runner was last active. |
ℹ️ No output is
sensitive— this resource holds no secret material.
- Cost-capped by default.
maximum_runners = 1— a billed resource never ships unbounded; you raise the cap deliberately. - Network-exposure off by default.
public_ip_enabled = false; static-IP quota is spent only on explicit opt-in. - Immutability is documented, not discovered.
imageis labeled# FORCENEWin the variable schema, so a caller never plans a surprise replacement. - The type is the contract. Deeply-typed
imageobject, enum-validatedimage.source, cross-field validation (image_version⇒custom), and positive-integermaximum_runners— malformed input fails at plan time, before any API call. - 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; the one nested block (
image) is required and always rendered. - Only real attributes are emitted. Outputs mirror exactly what the provider exposes — no fabricated
node_id/arn.
| Scope / Permission | Required for | Notes |
|---|---|---|
Classic PAT — admin:org |
Create / read / update / delete org hosted runners | Broad org-admin scope; covers all hosted-runner operations. |
Classic PAT — manage_runners:org |
Managing self-hosted & hosted runners | Narrower, dedicated scope — prefer it over admin:org when available (least-privilege). |
| Fine-grained PAT / GitHub App — Organization → Self-hosted runners (Read & write) | All hosted-runner CRUD | The specific fine-grained permission for this resource. |
Runner-group id source |
runner_group_id wiring |
Referencing a numeric group id needs no extra scope on this identity. |
⚠️ This is an organization-admin, billed operation. The provider identity must be an organization owner holdingadmin: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 manage org hosted runners and fails with a403. SSO-protected orgs additionally require the PAT to be SSO-authorized for the org.
- Plan / edition & billing: GitHub-hosted larger runners require GitHub Team or Enterprise Cloud with Actions billing configured. They bill per-minute at higher rates than standard runners — this is a cost-bearing resource.
- Image & size availability: The chosen
image.idandsizemust be available to the org. List them viaGET /orgs/{org}/actions/hosted-runners/images/github-ownedandGET /orgs/{org}/actions/hosted-runners/machine-sizes. - Static IP entitlement:
public_ip_enabled = trueis Enterprise/quota-gated and subject to per-account limits — checkGET /orgs/{org}/actions/hosted-runners/limitsbefore enabling. - Runner group first: A target runner group should exist before this runner references it (wire
runner_group_idfromtf-mod-github-actions-runner-group), or use the built-in Default group (id = 1). - Organization owner & org-only: The provider identity must be an org owner; this resource cannot be used with individual user accounts.
- API rate limits (bulk
for_each): Each runner is a separate set of REST calls; large fleets can hit secondary rate limits (403 secondary rate limit). Apply in batches or lower-parallelism. Provisioning is asynchronous — creation can take several minutes.
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 runner into state:
terraform import github_actions_hosted_runner.this <runner_id>| Symptom | Cause | Resolution |
|---|---|---|
403 on create/update |
Token lacks org-admin scope, or identity is not an org owner | Use a PAT with admin:org / manage_runners:org, or a fine-grained token with Self-hosted runners (read & write); ensure org-owner + SSO authorization. |
Billing / 402-style error or "larger runners not available" |
Actions billing not configured, or plan doesn't support larger runners | Configure Actions billing; upgrade to Team or Enterprise Cloud. |
| Plan shows the runner being replaced | image changed — it is ForceNew |
Expected. GitHub can't re-image in place. Apply during a maintenance window; in-flight jobs on the old runner are lost. |
image_version is only valid when image.source = "custom" |
image_version set on a github/partner image |
Remove image_version, or set image.source = "custom". |
| Size rejected / unavailable | The size isn't offered for that image/org/plan |
List valid sizes via the machine-sizes API; pick an available one. |
| Apply fails enabling static IP | Static-IP quota/entitlement missing | Check the limits API; leave public_ip_enabled = false unless entitled. |
Runner stuck Provisioning |
GitHub provisioning is asynchronous | Allow several minutes; check status output. Deletion likewise polls up to ~10 min. |
| Jobs queue and never pick up | maximum_runners cap reached |
Raise maximum_runners (a reviewed cost decision). |
403 secondary rate limit on bulk for_each |
Too many runners created in one burst | Lower -parallelism, split into batches, or retry after a short backoff. |
Constant drift on runner_group_id |
A non-existent or wrong group id supplied | Wire module.actions_runner_group.id, or use 1 for the default group. |
tf-mod-github-actions-runner-group— the runner group this runner joins; emits theidconsumed here.tf-mod-github-actions-organization— org-level Actions policy (allowed actions, enabled repos) that complements runners.- The keystone
tf-mod-github-repository— emitsrepo_idused to scope runner groups. integrations/githubprovider — Actions Hosted Runner resource reference.- GitHub Docs — About larger runners and Managing GitHub-hosted runners for an organization.