Skip to content

microsoftexpert/tf-mod-github-actions-hosted-runner

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

5 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🐙 GitHub Actions Hosted Runner Terraform Module

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.

Terraform GitHub provider module type resources


🧩 Overview

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-on target) and assigns it to a runner group (runner_group_id).
  • 💿 Pins the image (imageFORCENEW) 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, default false; Enterprise/quota-gated).
  • 🛠️ Supports custom images (image_version) for teams building their own runner images.
  • 📤 Emits id plus 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.


❤️ 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

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
Loading

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.


🧬 What this module builds

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
Loading

📁 Module Structure

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

⚙️ Quick Start

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.


🔌 Typical wiring

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_id takes the numeric runner-group ID. Wire it from module.actions_runner_group.id (the runner-group module's primary output), or use the org default group id 1.


🧠 Architecture Notes

  • id is the numeric hosted-runner ID — not a name or node id. github_actions_hosted_runner.this.id is the integer ID GitHub assigns. It is also the value you pass to terraform import github_actions_hosted_runner.this <id>.
  • No node_id / full_name / slug / html_url — and no arn / 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.
  • image is immutable (FORCENEW) — size is not. GitHub cannot re-image a runner in place, so any change to image.id or image.source destroys 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 contrast size is 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: size is not ForceNew, and platform is not an input at all.

  • platform is computed, not configured. You choose the OS/arch implicitly via the image you select; platform (linux-x64, win-x64, …) is reported back by GitHub as an output. There is no platform input or validation.
  • maximum_runners is the cost lever, and it is never unbounded. It defaults to 1 — 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_enabled defaults to false. Static IPs are subject to per-account limits and are an Enterprise/quota-gated feature — enabling it can fail apply on orgs without the entitlement. The assigned ranges surface in the public_ips output (populated only when enabled). Check limits via GET /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 via GET /orgs/{org}/actions/hosted-runners/images/github-owned; list sizes via GET /orgs/{org}/actions/hosted-runners/machine-sizes. image.source is one of github / partner / custom; image_version applies only to custom images (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, no timeouts surface. GitHub has no tagging concept, and this module follows the convention of not surfacing a timeouts block. (For completeness: the underlying provider does expose a timeouts { 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_value surface — nothing in this resource is sensitive.

📚 Example Library

ℹ️ 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 = 1 and has public_ip_enabled = false. You opt up into cost and network exposure — never the reverse.


📦 Inputs (high-level)

Identity

  • name (string, required) — unique runner name within the org; the runs-on target. 1–64 chars [A-Za-z0-9._-].

Placement

  • runner_group_id (number, required) — numeric runner-group ID. Wire from module.actions_runner_group.id, or 1 for the org default group.

Runner definition

  • image (object, required, FORCENEW){ id = string, source = optional(string, "github") }. sourcegithub/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, default 1) — concurrency/cost cap; never unbounded. Must be ≥ 1.

Networking

  • public_ip_enabled (bool, default false) — static egress IP; Enterprise/quota-gated.

Custom images

  • image_version (string, default null) — only valid when image.source = "custom" (validated).

🧾 Outputs

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.


🧱 Design Principles

  • 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. image is labeled # FORCENEW in the variable schema, so a caller never plans a surprise replacement.
  • The type is the contract. Deeply-typed image object, enum-validated image.source, cross-field validation (image_versioncustom), and positive-integer maximum_runners — malformed input fails at plan time, before any API call.
  • Auth & org are provider concerns. No owner / token / app_auth / base_url variables — 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.

🔑 Required token scopes / GitHub App permissions

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 holding admin:org (or manage_runners:org) on a classic PAT, or the Self-hosted runners (read & write) organization permission on a fine-grained PAT / GitHub App. A bare repo or repository-scoped token cannot manage org hosted runners and fails with a 403. SSO-protected orgs additionally require the PAT to be SSO-authorized for the org.


🧰 GitHub Prerequisites

  • 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.id and size must be available to the org. List them via GET /orgs/{org}/actions/hosted-runners/images/github-owned and GET /orgs/{org}/actions/hosted-runners/machine-sizes.
  • Static IP entitlement: public_ip_enabled = true is Enterprise/quota-gated and subject to per-account limits — check GET /orgs/{org}/actions/hosted-runners/limits before enabling.
  • Runner group first: A target runner group should exist before this runner references it (wire runner_group_id from tf-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.

🚀 Runbook

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>

🔍 Troubleshooting

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.

🔗 Related Docs

  • tf-mod-github-actions-runner-group — the runner group this runner joins; emits the id consumed here.
  • tf-mod-github-actions-organization — org-level Actions policy (allowed actions, enabled repos) that complements runners.
  • The keystone tf-mod-github-repository — emits repo_id used to scope runner groups.
  • integrations/github provider — Actions Hosted Runner resource reference.
  • GitHub Docs — About larger runners and Managing GitHub-hosted runners for an organization.

About

No description or website provided.

Topics

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors

Languages