paulserban.eu

Writing Edition

Paul Serban

Writing, snippets & book notes

January 20, 2022

GitHub Actions Runners: A Deep Dive from Basics to Advanced Architecture

Understanding the Engine Behind Every CI/CD Pipeline on GitHub

Introduction

Every workflow that runs on GitHub Actions eventually resolves down to a single unglamorous fact: some machine, somewhere, has to execute your job's steps. That machine is the runner, and while most engineers interact with it only through a runs-on: line in a YAML file, the runner is actually a fairly sophisticated piece of infrastructure. It negotiates work assignment with GitHub's backend, manages ephemeral execution environments, streams logs back in near real time, and - in self-hosted configurations - becomes a piece of infrastructure your team is directly responsible for securing, scaling, and maintaining.

This article works through runners from the ground up. We start with what a runner actually is and how it fits into the broader Actions architecture, then move into the mechanics of GitHub-hosted versus self-hosted runners, the internals of the runner agent itself, and finally the operational concerns that show up once you're running self-hosted fleets at scale: autoscaling, security hardening, and cost control. The goal is not to repeat the GitHub documentation, but to build a mental model precise enough that you can reason about failures, security boundaries, and scaling decisions rather than just copying configuration snippets.

Context: Why Runners Deserve Their Own Mental Model

It's tempting to treat the runner as an implementation detail - a black box that "just runs your job." For small projects using standard ubuntu-latest runners, that's a reasonable simplification. But the moment a team scales past trivial CI needs - larger build matrices, GPU workloads, on-premises database dependencies, strict compliance boundaries, or cost pressure from minute-based billing - the runner stops being invisible and starts being an architectural decision.

The core tension is this: GitHub-hosted runners are fully managed, ephemeral, and zero-maintenance, but they're also generic, rate-limited in capacity, and billed per minute with multipliers that vary by operating system. Self-hosted runners give you control over hardware, networking, and cost structure, but they shift the entire security and operational burden - patching, scaling, isolation, secret handling - onto your team. Neither option is universally correct, and the right choice often changes as a project matures from "a few CI jobs" to "hundreds of pipelines running around the clock."

Understanding runners well also matters because a huge fraction of real-world CI incidents - flaky builds, mysteriously slow jobs, security breaches involving fork-based pull requests, or autoscalers that either lag behind demand or bleed money by over-provisioning - trace back to a shallow understanding of how the runner actually behaves. A runner is not a stateless function invocation; it is a long-lived (or deliberately short-lived) agent process with its own lifecycle, network requirements, and failure modes. Treating it as a black box eventually produces incidents that are hard to diagnose precisely because the mental model was wrong from the start.

Finally, the runner is the natural boundary where CI/CD intersects with broader platform engineering. Decisions about runner infrastructure - Kubernetes-based autoscaling, VM image pipelines, network segmentation - are platform engineering decisions, not just "the CI config." Teams that recognize this early tend to build more resilient, auditable, and cost-efficient pipelines than teams that keep runner concerns buried inside individual workflow files.

Deep Technical Explanation: How the Runner Actually Works

At its core, a GitHub Actions runner is an application - open-sourced by GitHub at actions/runner - that registers itself with GitHub, listens for job assignments, executes them, and streams results back. It is not GitHub polling your machine; it's the runner polling GitHub. This distinction matters architecturally: a self-hosted runner behind a corporate firewall with no inbound ports open can still work perfectly well, because all communication is outbound HTTPS initiated by the runner itself using long-polling against GitHub's Actions service.

When a workflow triggers, GitHub's backend evaluates the runs-on field and any associated labels, then places the job into a queue scoped to the matching runner pool. For GitHub-hosted runners, GitHub provisions a fresh virtual machine from a pre-built image (maintained in the actions/runner-images repository), starts the runner application inside it, and tears the VM down after the job completes - every job gets a clean, single-use environment. For self-hosted runners, an already-running runner process (or one spun up by an autoscaler) picks up the job from the queue it's registered against.

The runner itself operates as two cooperating processes: a listener process that maintains the connection to GitHub and receives job messages, and a worker process spawned per job that actually executes the steps. This separation exists so that a crash or hang inside job execution doesn't necessarily take down the listener, and so that the runner can enforce timeouts and cancellation cleanly. Each step in a job - whether it's a shell command, a JavaScript action, or a Docker container action - runs inside this worker process, with environment variables, secrets, and outputs passed through a structured protocol rather than raw shell interpolation, which is part of why action inputs are comparatively safer than naive shell scripting once you follow GitHub's conventions.

Registration security has evolved meaningfully over the runner's lifetime. Early self-hosted setups used long-lived registration tokens that, if leaked, could be used to register a rogue runner into an organization's pool. GitHub has since moved toward just-in-time (JIT) runner tokens and short-lived registration flows, particularly in the context of actions-runner-controller, which reduces the blast radius of a leaked credential. Understanding this evolution matters if you're auditing an older self-hosted setup, since a configuration written a few years ago may still rely on patterns GitHub now considers legacy.

Implementation: Configuring and Using Runners in Practice

The simplest and most common case is a GitHub-hosted runner, declared with a single label:

name: CI
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
      - run: npm ci
      - run: npm test

This is enough for the vast majority of open-source and small-to-mid-size projects. But once you introduce self-hosted infrastructure, teams often need programmatic visibility into runner health rather than relying purely on the GitHub UI. The following TypeScript example uses Octokit to check for self-hosted runners that have gone offline - a realistic pattern for an internal health-check job or a Slack alerting bot:

import { Octokit } from "@octokit/rest";

interface RunnerHealth {
  name: string;
  status: "online" | "offline";
  busy: boolean;
}

async function findOfflineRunners(
  octokit: Octokit,
  org: string
): Promise<RunnerHealth[]> {
  const { data } = await octokit.actions.listSelfHostedRunnersForOrg({
    org,
    per_page: 100,
  });

  const runners: RunnerHealth[] = data.runners.map((r) => ({
    name: r.name,
    status: r.status as "online" | "offline",
    busy: r.busy,
  }));

  const offline = runners.filter((r) => r.status === "offline");

  if (offline.length > 0) {
    console.warn(
      `Found ${offline.length} offline runner(s): ${offline
        .map((r) => r.name)
        .join(", ")}`
    );
  }

  return offline;
}

A script like this is typically run on a schedule (via its own lightweight Actions workflow or an external cron job) so that infrastructure drift - a runner VM that silently died, or a Kubernetes pod stuck in CrashLoopBackOff - surfaces before it causes queued jobs to stall indefinitely.

Scaling Self-Hosted Runners

Once a team commits to self-hosted runners, the next problem is almost always scaling: provisioning enough capacity to avoid queue backlogs without paying for idle machines around the clock. Static pools of always-on VMs are the simplest approach but scale poorly with bursty CI traffic - a typical pattern where dozens of jobs queue up right after a merge to main and then nothing happens for hours.

The dominant solution in the Kubernetes ecosystem is actions-runner-controller (ARC), a controller that GitHub now maintains directly. ARC watches the GitHub Actions job queue and reconciles the number of running runner pods to match demand, scaling down to zero when there's no work. Newer versions of ARC use an architecture based on "autoscaling runner sets," which register ephemeral runners just-in-time for each job rather than keeping a pool of pre-registered idle runners, closing some of the security gaps associated with long-lived self-hosted agents. A minimal reconciliation loop conceptually resembles the following simplified Python pseudo-implementation of the scaling decision logic an operator might build on top of queue metrics:

from dataclasses import dataclass

@dataclass
class ScalingDecision:
    desired_replicas: int
    reason: str

def compute_desired_replicas(
    queued_jobs: int,
    running_jobs: int,
    current_replicas: int,
    max_replicas: int,
    min_replicas: int = 0,
) -> ScalingDecision:
    demand = queued_jobs + running_jobs

    if demand == 0:
        return ScalingDecision(min_replicas, "no pending or running work")

    desired = min(demand, max_replicas)

    if desired > current_replicas:
        return ScalingDecision(desired, "scaling up to match queue depth")
    if desired < current_replicas:
        return ScalingDecision(desired, "scaling down to reduce idle capacity")

    return ScalingDecision(current_replicas, "no change required")

Outside Kubernetes, teams running on plain cloud VMs often implement similar logic against cloud-provider autoscaling groups (AWS Auto Scaling Groups, GCP Managed Instance Groups), using the GitHub API's queued-job metrics or webhook events as the scaling signal instead of CPU utilization, since CI workloads don't correlate well with the metrics traditional autoscalers were built around.

A less obvious but important scaling lever is ephemeral versus persistent runner mode. Ephemeral runners deregister and terminate after a single job, which is the safer default because it guarantees a clean environment and eliminates cross-job state leakage, but it adds provisioning latency to every job. Persistent runners avoid that startup cost but accumulate state across jobs unless carefully sandboxed, and require more deliberate cleanup logic (clearing workspace directories, resetting Docker state) between runs.

Trade-offs and Pitfalls

The most consequential pitfall in self-hosted runner adoption is using self-hosted runners on public repositories without additional safeguards. Because anyone can open a pull request against a public repo, and because pull_request_target and similarly privileged workflow triggers can execute with access to secrets, a misconfigured self-hosted runner on a public repository can effectively hand arbitrary code execution capability to an external contributor - a class of attack often referred to informally as a "pwn request." GitHub's own documentation explicitly warns against this configuration, and the safer pattern is to restrict self-hosted runners to private repositories, or to gate any privileged workflow behind manual approval for first-time contributors.

A second common pitfall is underestimating image drift on self-hosted infrastructure. GitHub-hosted runners come from a well-maintained, versioned image pipeline, so "the CI environment" is consistent and reproducible by default. Self-hosted runner images, by contrast, are the team's responsibility, and it's easy for a base VM image to quietly diverge from what developers run locally - different compiler versions, different system libraries - producing "works on my machine but not in CI" failures that are genuinely caused by infrastructure inconsistency rather than code defects. Treating runner images as versioned, reviewed artifacts (built via a documented Packer or Dockerfile pipeline, not hand-patched servers) avoids this category of problem entirely.

Best Practices

A few practices consistently separate mature Actions setups from fragile ones. First, prefer ephemeral, single-job runners wherever feasible, even at the cost of some startup latency, because the security and reproducibility benefits generally outweigh the performance cost - and for many workloads, container-based ephemeral runners start fast enough that the difference is negligible.

Second, scope self-hosted runners with runner groups and labels deliberately rather than registering everything into one large pool. Runner groups let organization administrators restrict which repositories can target which runners, which is essential once you have runners with elevated network access (for example, runners that can reach an internal database or artifact registry) sitting alongside runners meant for generic public-facing CI.

Third, invest in observability specifically for runner infrastructure, not just for the workflows running on top of it. Queue depth, time-to-pickup (how long a job waits before a runner claims it), and runner utilization are all metrics that matter independently of whether individual jobs pass or fail, and they're the leading indicators that tell you an autoscaler is misconfigured before developers start complaining about slow CI. Finally, keep runner registration credentials and any long-lived tokens out of workflow files and static configuration; use GitHub's JIT token flows or short-lived credentials wherever the runner platform (ARC, custom autoscalers) supports them, and rotate anything that can't be made short-lived.

Key Takeaways

Conclusion

The runner is easy to ignore when everything works, which is precisely why it's worth understanding before something breaks. Whether you're debugging a job that mysteriously sits queued for twenty minutes, deciding whether a new internal tool justifies the operational cost of self-hosted infrastructure, or auditing a security report about exposed CI credentials, the quality of your reasoning depends on having an accurate model of what the runner is actually doing - a polling agent with a defined lifecycle, not a magic execution slot.

As GitHub continues to invest in the runner ecosystem - JIT registration, autoscaling runner sets, larger managed runner tiers - the gap between "using Actions" and "operating Actions infrastructure" will keep shifting, but the fundamentals covered here are unlikely to change: runners poll rather than get pushed to, self-hosted infrastructure trades convenience for control, and security boundaries around who can trigger privileged runners deserve the same scrutiny as any other piece of production infrastructure.

References

  1. GitHub Docs - "About self-hosted runners," https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/about-self-hosted-runners
  2. GitHub Docs - "Security hardening for GitHub Actions," https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions
  3. GitHub open-source runner agent - actions/runner, https://github.com/actions/runner
  4. GitHub-hosted runner image build definitions - actions/runner-images, https://github.com/actions/runner-images
  5. GitHub's official Kubernetes runner autoscaler - actions/actions-runner-controller, https://github.com/actions/actions-runner-controller
  6. GitHub Docs - "Autoscaling with self-hosted runners," https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/autoscaling-with-self-hosted-runners
  7. GitHub Docs - "About larger runners," https://docs.github.com/en/actions/using-github-hosted-runners/about-larger-runners
  8. GitHub Docs - "Managing access to self-hosted runners using groups," https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/managing-access-to-self-hosted-runners-using-groups
  9. Octokit REST.js client library documentation, https://octokit.github.io/rest.js/

A quick flashcard preview - one example of each question type in this post. This is a demo of what you'll find in the full quiz app.

Flashcard preview - 1 / 9Multiple Choice

multiple choice - intermediate - auto-graded

What is the purpose of runner groups in a GitHub organization?

Choose an answer