Blog

Self-hosted GitHub Actions runners without Kubernetes

Most answers begin with a cluster. Here is what a clean machine for each job takes without one, and four ways to get it.

Your own runners, without Kubernetes. Three machines stacked, with blue chrome rims.

Search for how to run your own GitHub Actions runners that scale, and most answers begin with a Kubernetes cluster. If you have one, that is a good answer. If you do not, starting one for the sake of CI is a lot of machinery for a simple wish: a clean machine when a job is queued, gone when the job is done.

What GitHub recommends

GitHub’s reference for self-hosted runners says two things worth knowing. The first is about how runners should live:

GitHub recommends implementing autoscaling with ephemeral self-hosted runners; autoscaling with persistent self-hosted runners is not recommended.

An ephemeral runner takes one job and is removed. The second is that Actions Runner Controller is “the recommended Kubernetes-based solution”. The first is advice for everyone. The second only applies if Kubernetes is where you want your jobs.

What the cluster costs you

Actions Runner Controller runs each runner as a pod. GitHub’s support page for it recommends “staff with expert-level knowledge of container orchestration”. Jobs that build or run containers need Docker-in-Docker in privileged mode, or a Kubernetes mode in which every job must name a container. And GitHub advises a cluster of its own for it, because a shared one “could pose a security risk”.

None of that is a flaw. It is what running arbitrary code in a cluster takes.

What one job per machine needs

Without a cluster, the work is small enough to list:

  1. Hear from GitHub that a job is queued (a webhook).
  2. Ask GitHub for a runner registration good for one job. GitHub calls these just-in-time runners: they “perform at most one job before being automatically removed”.
  3. Start a machine of the right size, with that registration.
  4. End the machine when the job is done.
  5. Clean up what went wrong: a machine whose job never came, a job whose machine never started.

A virtual machine for each job also settles the Docker question. The job has a whole machine, so docker build, services and container jobs work as they do on GitHub’s own runners.

Ways to get it

  • Build it. GitHub publishes a Runner Scale Set Client, a Go module for writing your own autoscaler for machines or containers.
  • terraform-aws-github-runner. An open-source Terraform module: Lambda functions start EC2 machines as jobs arrive. Runners that take one job are an option you turn on.
  • RunsOn. A CloudFormation stack in your AWS account. Commercial use needs a licence.
  • SuperCI, which we make. Open source under the MIT licence, set up from a dashboard, and not only for AWS.

We compare SuperCI with each of the other three here.

With SuperCI

npx @superci/cli dashboard

That opens a dashboard on your own computer. Sign in with AWS, Cloudflare or Modal; it puts a small control plane there (on AWS, one Lambda function) and makes a GitHub App that is yours. Then one line in a workflow:

jobs:
  test:
    runs-on: superci

Each job gets a fresh machine, registered for exactly that job, and the machine is ended when the job ends. Nothing but the function runs in between.

One caution

GitHub warns that self-hosted runners “should almost never be used for public repositories”, because anyone can open a pull request that runs code on them. SuperCI refuses jobs from public repositories until you allow a repository by name, and refuses a fork’s pull requests even then. Whatever you use, check what it does with them. Public repositories has how SuperCI handles it.