quickstart →

Fleet installation guide

Deploy RunsOn Fleet on AWS with Terraform or OpenTofu.

What changes in your workflows#

Repositories change one line per job. They swap the GitHub-hosted label for the name of a runner fleet your platform team publishes:

.github/workflows/ci.yml
runs-on: ubuntu-latest
runs-on: runs-on/fleet=linux-small/env=production

That label stays the same. The fleet’s CPU, RAM, instance families, image, disk, networking and standby capacity live in Terraform, so the platform team can resize a fleet or move it to a new image in one reviewed change. Every workflow that targets the fleet gets the new runners without a commit. Workflows can’t override the shape per job. Use Flex for jobs that need that.

.github/workflows/ci.yml
jobs:
test:
runs-on: runs-on/fleet=linux-small/env=production
windows:
runs-on: runs-on/fleet=windows-large/env=production

How Fleet is deployed#

RunsOn Fleet is deployed with the official Terraform module. The module is available on the Terraform Registry ↗ and the OpenTofu Registry ↗.

Use Fleet when a platform team wants to publish approved runner fleets such as linux-small or linux-large and have repositories target those runner fleets with stable GitHub runner labels.

Prerequisites#

  • AWS credentials with permission to create ECS, IAM, EC2, S3, Secrets Manager, and CloudWatch resources.
  • Terraform or OpenTofu.
  • A VPC and at least one public subnet (or use the VPC module to create one). Private subnets are optional.
  • A RunsOn license key.
  • GitHub credentials: a GitHub App for organization mode, or a classic PAT for enterprise mode (see below).

GitHub credentials#

Fleet connects to GitHub with a single credential, depending on which boundary it manages:

  • Organization mode — a GitHub App installed on one organization.
  • Enterprise mode — a classic PAT scoped to the enterprise.

Fleet registers runners into a GitHub runner group but never creates one. Without fleets.<name>.runner_group, a fleet uses the Default runner group. To use another group, create it yourself (in Terraform or the GitHub UI) and reference it by name from fleets.<name>.runner_group. The examples below show this for both single-organization and enterprise topologies.

Organization mode: create a GitHub App#

Open the following URL, replacing <ORG> with the organization that will own the App. The query string pre-fills the App name, permissions, and the organization self-hosted-runners write scope:

https://github.com/organizations/<ORG>/settings/apps/new?name=RunsOn%20Fleet%20%5B<ORG>%5D&url=https%3A%2F%2Fruns-on.com&public=false&webhook_active=false&organization_self_hosted_runners=write&actions=write

Or enter your organization name to generate the link directly:

Enter an organization name to generate the link.

Then:

  1. Generate a private key and save the .pem file.
  2. Install the App on the organization (install it on exactly one).
  3. Pass the App ID and the .pem contents as github_app_id and github_app_private_key in the module.

Enterprise mode: create a classic PAT#

Open the following URL, replacing <ENTERPRISE> with your enterprise slug. The query string pre-fills the token description and the manage_runners:enterprise scope:

https://github.com/settings/tokens/new?description=RunsOn%20Fleet%20%5B<ENTERPRISE>%5D&scopes=manage_runners%3Aenterprise

Pass this PAT as github_enterprise_pat in the module.

This token runs jobs, but it cannot re-run them after a Spot interruption. For that, add the repo scope, which also gives the token access to repositories:

https://github.com/settings/tokens/new?description=RunsOn%20Fleet%20%5B<ENTERPRISE>%5D&scopes=manage_runners%3Aenterprise,repo

Enterprise mode uses a personal access token only because GitHub does not yet let a GitHub App manage enterprise runners.

For GHES, replace https://github.com in either URL with your GHES host root, and pass that host root as github_base_url to the module.

Single organization#

This example creates an organization runner group, a small-x64 runner shape, and one linux-small fleet that registers into that runner group.

main.tf
provider "github" {
# GitHub App auth, or a PAT scoped to manage the org runner group.
}
resource "github_actions_runner_group" "platform" {
name = "platform"
visibility = "all"
}
module "runs_on_fleet" {
source = "runs-on/runs-on/aws//modules/fleet"
version = "v3.5.0"
stack_name = "runs-on-fleet"
license_key = var.license_key
email = "alerts@example.com"
environment = "production"
github_app_id = var.github_app_id
github_app_private_key = var.github_app_private_key
vpc_id = var.vpc_id
public_subnet_ids = var.public_subnet_ids
# Optional: ubuntu24-full-x64 is a built-in image name. Declare it only
# when you want to override the default AMI lookup pattern or owner.
images = {
ubuntu24-full-x64 = {
name = "runs-on-v2.2-ubuntu24-full-x64-*"
owner = "898082745236"
platform = "linux"
arch = "x64"
}
}
runners = {
small-x64 = {
cpu = 2
family = ["m8", "r8"]
image = "ubuntu24-full-x64"
}
}
fleets = {
linux-small = {
runner = "small-x64"
runner_group = github_actions_runner_group.platform.name
}
}
}

To restrict the runner group to specific repositories, set visibility = "selected" and pass selected_repository_ids on the github_actions_runner_group resource. Org-level runner group access is fully manageable in Terraform.

After terraform apply, target the fleet from a workflow:

.github/workflows/ci.yml
jobs:
build:
runs-on: runs-on/fleet=linux-small/env=production
steps:
- uses: runs-on/action@v2
- uses: actions/checkout@v7
- run: echo "Hello from RunsOn Fleet"

To route organization Dependabot jobs to a runner fleet, see Dependabot.

Enterprise#

In enterprise mode, Fleet registers enterprise-owned runner scale sets. This example creates an enterprise runner group with the integrations/github provider and connects two runner fleets to it.

The example also enables private networking (private_mode = "only") to show what a more locked-down deployment looks like. Private mode is not required: you can run enterprise mode with public subnets exactly like the single-org example, just by setting github_enterprise_pat / github_enterprise_name and dropping the private-mode and NAT pieces.

main.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
github = {
source = "integrations/github"
version = "~> 6.0"
}
}
}
provider "aws" {
region = var.aws_region
}
provider "github" {
token = var.github_enterprise_pat
}
data "aws_availability_zones" "available" {
state = "available"
}
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 6.0"
name = "runs-on-fleet-vpc"
cidr = "10.20.0.0/16"
azs = slice(data.aws_availability_zones.available.names, 0, 2)
private_subnets = ["10.20.128.0/20", "10.20.144.0/20"]
public_subnets = ["10.20.0.0/20", "10.20.16.0/20"]
enable_nat_gateway = true
single_nat_gateway = true # Use false for one NAT Gateway per AZ.
enable_dns_hostnames = true
enable_dns_support = true
}
module "vpc_endpoints" {
source = "terraform-aws-modules/vpc/aws//modules/vpc-endpoints"
version = "~> 6.0"
vpc_id = module.vpc.vpc_id
endpoints = {
s3 = {
service = "s3"
service_type = "Gateway"
route_table_ids = module.vpc.private_route_table_ids
}
}
}
resource "github_enterprise_actions_runner_group" "runs_on" {
name = "runs-on-fleet"
enterprise_slug = var.github_enterprise_name
visibility = "all"
allows_public_repositories = false
}
module "runs_on_fleet" {
source = "runs-on/runs-on/aws//modules/fleet"
version = "v3.5.0"
stack_name = "runs-on-fleet"
license_key = var.license_key
email = "alerts@example.com"
environment = "production"
github_enterprise_pat = var.github_enterprise_pat
github_enterprise_name = var.github_enterprise_name
vpc_id = module.vpc.vpc_id
public_subnet_ids = module.vpc.public_subnets
private_subnet_ids = module.vpc.private_subnets
private_mode = "only"
# Optional: ubuntu24-full-x64 is a built-in image name. Declare it only
# when you want to override the default AMI lookup pattern or owner.
images = {
ubuntu24-full-x64 = {
name = "runs-on-v2.2-ubuntu24-full-x64-*"
owner = "898082745236"
platform = "linux"
arch = "x64"
}
}
runners = {
linux-small = {
cpu = 2
family = ["m8", "r8"]
image = "ubuntu24-full-x64"
}
windows-large = {
cpu = 8
ram = 16
extras = []
family = ["c8i", "c8a"]
image = "windows25-full-x64"
}
}
fleets = {
linux-small = {
runner = "linux-small"
runner_group = github_enterprise_actions_runner_group.runs_on.name
}
windows-large = {
runner = "windows-large"
runner_group = github_enterprise_actions_runner_group.runs_on.name
timezone = "UTC"
schedule = [
{
name = "default"
hot = 1
stopped = 3
},
]
}
}
}

This creates two runner fleets:

  • runs-on/fleet=linux-small/env=production
  • runs-on/fleet=windows-large/env=production

windows25-full-x64 is also a predefined image name, so it does not need an images entry.

Picking which orgs and repos can use the runner group#

visibility = "all" lets every org in the enterprise see the runner group. To restrict to specific orgs, set visibility = "selected" and pass selected_organization_ids.

There is no field for selecting specific repositories on an enterprise runner group, neither in Terraform nor in the GitHub UI. To restrict repos within a permitted org, an org admin has to open the org’s Settings → Actions → Runner groups, click the shared enterprise runner group, and pick repositories from there.

NAT Gateway and private mode#

Because this example uses private_mode = "only", runners launch in private subnets and need a route to the public internet for github.com, container registries, and package mirrors. That is what the NAT Gateway provides. If you don’t want a NAT Gateway, switch to public subnets by removing private_mode and private_subnet_ids.

The S3 gateway endpoint keeps S3 cache traffic on the AWS network path without adding hourly endpoint cost.

Next steps#