Self-hosted runners start as a cost or speed optimization: bigger machines, warm caches, access to a private network. They end up as a security decision. A self-hosted runner executes code from your repository, on a machine you own, usually with network access you did not give to GitHub’s hosted runners. Anyone who can influence a workflow can run commands there.

This article covers when self-hosted runners are worth it, how the runner actually works, how to isolate jobs, how to scale a fleet, and the mistakes that turn a CI convenience into a breach path.

When Self-Hosted Runners Make Sense

GitHub-hosted runners are the right default: a clean virtual machine per job, patched images, no maintenance. Move to self-hosted when you have a concrete need:

  • Private network access. Deploying to a database or cluster that is not reachable from the public internet, without punching holes for GitHub’s IP ranges.
  • Specialized hardware. GPUs, ARM boards, specific macOS hardware, large memory, or fast local NVMe.
  • Large, stable workloads. Heavy build farms where you can keep machines busy and caches warm.
  • Licensing or compliance requirements that tie builds to specific infrastructure.
  • Persistent large caches, such as a monorepo with a huge dependency tree, where restoring caches over the network dominates the job time.

Reasons that are weaker than they look: “it is cheaper” (compare honestly, including your time, patching and idle capacity), and “it is faster” (often true for cache-heavy builds, rarely a reason by itself). If your jobs are small and bursty, hosted runners are usually better. For workflow design that reduces build time first, see GitHub Actions Advanced Patterns.

How the Runner Works

The runner is an agent that you install on a machine. It registers with GitHub using a token and then long-polls for jobs. Notable properties:

  • Outbound only. The runner connects out to GitHub over HTTPS. You do not need to expose an inbound port.
  • It runs as a local user. Whatever that user can access, a job can access. This includes the Docker socket if the user is in the docker group.
  • State persists by default. The working directory, caches, installed tools and leftover files remain between jobs unless you clean them or destroy the machine.
  • Labels route jobs. runs-on: [self-hosted, linux, x64, gpu] matches runners carrying all those labels.
  • Scope of registration. A runner can be registered to a repository, an organization or an enterprise. Organization runners are shared through runner groups, which control which repositories may use them.
jobs:
  build:
    runs-on: [self-hosted, linux, x64]
    steps:
      - uses: actions/checkout@v4
      - run: make build

The default persistent behaviour is the root of most problems: job N can leave something behind that affects job N+1, whether by accident or by design.

The Threat Model

Think of a runner as a machine where semi-trusted code runs on a schedule. The threats:

  1. Pull requests from forks on public repositories. A fork’s workflow can change the code that executes. If your self-hosted runner is available to a public repository, a stranger can open a pull request and run arbitrary code on your machine. GitHub’s own documentation recommends against using self-hosted runners with public repositories for this reason. If you must, require approval for workflow runs from outside contributors and keep those jobs off runners with access to anything valuable.
  2. Persistence between jobs. A malicious job can plant a script in a cache directory, modify a tool on PATH, or leave a background process. The next job, possibly for a more sensitive workflow, executes it.
  3. Credential exposure. Anything on the host is reachable: cloud credentials from instance metadata, SSH keys, other jobs’ workspaces, the Docker socket.
  4. Lateral movement. A runner inside a private network is a foothold. If it can reach production databases, a compromised workflow can too.
  5. Script injection through untrusted input. Expressions such as ${{ github.event.pull_request.title }} interpolated directly into a run: step let a pull request author inject shell commands. This applies to hosted runners too, but the impact is larger on a runner with network reach. Pass untrusted values through environment variables instead:
- name: Greet
  env:
    PR_TITLE: ${{ github.event.pull_request.title }}
  run: echo "Title is $PR_TITLE"
  1. Supply-chain risk from third-party actions. An action runs with the job’s access. Pin actions to a full commit SHA for anything sensitive, and review what you adopt.

Isolation: Ephemeral Runners

The most important single control is making runners ephemeral: one job, then the runner and its environment are destroyed.

The runner supports this natively with the --ephemeral flag. The runner takes a single job, then unregisters itself and exits:

./config.sh \
  --url https://github.com/example-org \
  --token "$REGISTRATION_TOKEN" \
  --ephemeral \
  --labels linux,x64,ephemeral \
  --unattended
./run.sh

Ephemeral by itself only guarantees a single job per registration. You still need a fresh filesystem each time. Ways to get it:

  • A fresh virtual machine per job. Strongest isolation. Boot from an image, register, run one job, destroy the VM. The cost is startup time, which you can reduce with pre-baked images and warm pools.
  • A fresh container per job. Faster, weaker isolation, because containers share the host kernel. Acceptable for trusted internal repositories, less so for anything touching outside contributions.
  • Hardened container runtimes such as gVisor or Kata Containers sit between the two, adding a barrier between job and host kernel.

Do not mount the Docker socket

Many CI jobs build images. Giving the runner container /var/run/docker.sock gives every job root-equivalent control of the host. Alternatives:

  • Run a rootless Docker or Podman daemon per runner.
  • Use Docker-in-Docker with a dedicated, disposable daemon per job.
  • Build with a daemonless builder such as Buildah, Kaniko, or BuildKit in rootless mode.
  • Build on a separate, isolated build host reached through a narrow interface.

Least privilege on the host

  • Run the runner as a dedicated unprivileged user, never root, with no sudo.
  • Do not store long-lived cloud credentials on the machine. Prefer short-lived tokens obtained at runtime through OIDC federation, so the job exchanges its identity for scoped, time-limited cloud credentials.
  • Put runners on their own network segment with egress rules. Allow what builds need (package registries, GitHub, your artifact store) and deny the rest, especially instance metadata endpoints and internal admin networks.
  • Use separate runners, or runner groups, for sensitive workflows such as deployment, distinct from those used for pull request validation.
permissions:
  contents: read
  id-token: write   # needed only for OIDC to the cloud provider

jobs:
  deploy:
    runs-on: [self-hosted, linux, deploy]
    environment: production
    steps:
      - uses: actions/checkout@v4
      - run: ./deploy.sh

Setting permissions at the workflow or job level limits what the job’s GITHUB_TOKEN can do. Combine it with environment protection rules so production deploys need approval.

Scaling a Runner Fleet

A fixed set of always-on runners wastes money at night and queues jobs at peak. Options for scaling:

Kubernetes with Actions Runner Controller (ARC). GitHub’s supported controller runs runners as pods and scales on the number of queued jobs. Each runner pod is ephemeral. You can define runner scale sets with different images, resource requests and labels. It is a good fit if you already operate a cluster. The caveat is that pods share nodes, so for isolation you want separate node pools, restricted pod security settings, and no privileged containers if you can avoid them.

Cloud instances with autoscaling. A webhook receiver or a scaling controller reacts to workflow_job events (queued, in_progress, completed), launches an instance per queued job, and terminates it afterwards. This gives VM-level isolation. Several open-source projects implement this pattern for the major clouds.

A static pool with cleanup. For small teams and a single powerful machine, a pool of a few runners with a cleanup hook may be enough. If you go this way, wipe the workspace between jobs and accept that isolation is weak. Runner hooks can run scripts at job start and completion:

# set in the runner's .env file
ACTIONS_RUNNER_HOOK_JOB_STARTED=/opt/runner/hooks/job-started.sh
ACTIONS_RUNNER_HOOK_JOB_COMPLETED=/opt/runner/hooks/job-completed.sh
#!/usr/bin/env bash
# job-completed.sh: remove job leftovers
set -euo pipefail
rm -rf "${GITHUB_WORKSPACE:?}"/* "${GITHUB_WORKSPACE:?}"/.[!.]* 2>/dev/null || true

Scaling considerations that are easy to forget:

  • Queue time is the metric. Watch how long jobs wait for a runner, not CPU graphs.
  • Cold start budget. If a fresh VM takes minutes, keep a small warm pool.
  • Cache strategy. Ephemeral runners lose local caches. Use the Actions cache, a registry mirror, or a shared build cache service close to the runners.
  • Plan for saturation. When your fleet is full, jobs queue. Decide whether falling back to hosted runners is acceptable for non-sensitive jobs.

Operating the Fleet

  • Patch the base image on a schedule and rebuild. An ephemeral runner from a stale image is still a stale machine.
  • Keep the runner agent updated. GitHub requires reasonably current runner versions, and old agents stop receiving jobs. Ephemeral fleets built from fresh images avoid the problem.
  • Log centrally. Ship job logs and host audit logs somewhere the runner cannot modify, so a compromised runner cannot erase its trail. See Loki and Promtail centralized logging for one approach.
  • Monitor runner health. Track registered runners, idle versus busy counts, and queue wait.
  • Scope registration credentials. Registration tokens are short-lived by design. Do not bake one into an image. Use a controller that requests them on demand, with a GitHub App or token limited to runner administration.
  • Audit workflow changes. Use CODEOWNERS on .github/workflows/ so a workflow cannot be changed without review from the right people.

Common Mistakes

  • Attaching a self-hosted runner to a public repository with default fork approval settings.
  • Reusing one persistent runner for both pull request checks and production deployment.
  • Mounting the Docker socket into the runner container.
  • Leaving cloud credentials or SSH keys on the runner’s disk.
  • Treating a container as a security boundary against hostile code.
  • Interpolating untrusted github.event.* fields directly into run: scripts.
  • Never cleaning workspaces, so disks fill up and old secrets linger in checkout directories.
  • Forgetting egress controls, so a runner can reach metadata services and internal admin interfaces.

When Not to Self-Host

If you cannot commit to patching, monitoring and capacity management, hosted runners are the safer choice. If your only motivation is a modest cost saving, the maintenance and security work will probably cost more than it saves. And if your repositories accept contributions from people you do not know, keep them on hosted runners and put self-hosted capacity behind trusted branches and protected environments only.

Key Takeaways

  • Self-hosted runners are justified by private network access, special hardware or sustained heavy load, not by an assumption that they are always cheaper.
  • A runner executes repository code on your machine; never expose one to public repositories or untrusted forks.
  • Use ephemeral runners with a fresh VM or container per job, and avoid mounting the Docker socket.
  • Prefer OIDC short-lived credentials over stored secrets, apply least-privilege permissions, and restrict runner egress.
  • Scale on queue depth with Actions Runner Controller or an instance autoscaler, and plan caching around the loss of local state.
  • Separate runners for pull request validation and for deployment, and protect workflow files with code ownership and review.