What self-hosted spot runners are

A GitHub Actions runner is the agent that picks up a queued workflow job, executes its steps, and reports the result back to GitHub. GitHub-hosted runners are virtual machines that GitHub provisions, bills per minute, and discards after each job. A self-hosted runner is the same agent running on infrastructure you control.

Self-hosted spot runners run that agent on spot (preemptible) cloud capacity. This is spare compute that the provider sells at a discount and can reclaim at short notice. In practice this usually means ephemeral runner pods on a Kubernetes cluster, managed by Actions Runner Controller (ARC) and scheduled onto spot node pools. You trade a lower compute rate for interruption risk, infrastructure you have to operate, and security isolation you have to build yourself.

When to use self-hosted spot runners

Use them when most of the following are true:

  • CI volume is sustained and high. Fixed cluster costs and operating effort only pay off when they are spread across many jobs.
  • Most jobs are short and safe to rerun. Lint, unit tests, and builds that write only to the workspace tolerate a reclaimed node.
  • You need machines that are expensive or unavailable as hosted runners. Examples include high-memory builds, GPUs, or access to private network resources.
  • Your team already operates Kubernetes. The cluster, autoscaler, and on-call rotation exist already, so CI is an added workload and not a new platform.

Avoid them, or keep them to a small hybrid slice, when volume is low or spiky, when nobody owns the platform, or when the workload is dominated by long jobs or jobs with irreversible side effects such as deploys and migrations. The last section of this article turns these conditions into a decision table.

Cost per successful job, not cost per minute

Hosted runners bill per minute of job runtime. Self-hosted spot runners bill per instance-hour, whether a job is running or not. Comparing a per-minute list price to a spot hourly rate assumes every paid hour does useful work, and that assumption almost never holds. Nodes sit idle between bursts, warm capacity waits for jobs that may not arrive, and spot interruptions force reruns you pay for twice.

A fairer metric is cost per successful job:

Cost per job = [(J × D × (1 + r)) / U + W × 730] × P / J

  • J is monthly jobs.
  • D is average job hours.
  • r is the rerun rate from interruptions.
  • U is the busy fraction of autoscaled node-hours.
  • W is the number of always-on warm nodes.
  • P is the spot hourly price.

Illustrative estimate (assumed numbers, not a benchmark). The inputs are:

  • 20,000 jobs per month at 8 minutes each.
  • A hosted rate of $0.008/min, which gives $0.064 per job.
  • An on-demand price of $0.40/hr with a 65% spot discount, which gives $0.14/hr.
  • One job per node.
  • A rerun rate of 5%, utilization of 60%, and 2 warm nodes.
Scenario (illustrative) U r Monthly cost Cost per job
Hosted — — $1,280 $0.064
Spot baseline 60% 5% ~$858 ~$0.043
Spot, poor packing 30% 5% ~$1,511 ~$0.076
Spot, high interruptions 60% 20% ~$951 ~$0.048

Utilization moves the result more than any other input. At 30% utilization, spot costs more per job than hosted, even with a 65% discount. A higher interruption rate costs less than expected because a rerun only repeats one job's minutes. Long jobs change that, since an interruption late in a long job wastes most of its runtime.

The formula also leaves out engineering time, which the section on hidden self-hosted costs covers.

How self-hosted spot runners work

The mechanism has four parts:

  1. Demand detection. A controller watches GitHub for jobs queued against a named runner set.
  2. Runner creation. For each queued job, the controller starts a fresh runner that registers with GitHub, runs that single job, and is then destroyed.
  3. Capacity scaling. When runners cannot be scheduled, a node autoscaler adds spot instances. When nodes go idle, it removes them. If spot capacity is short or reclaimed, it falls back to on-demand nodes.
  4. Job placement. Each workflow job picks its runner set through runs-on. Interruption-tolerant jobs go to spot, and sensitive jobs go to on-demand or hosted runners.

The next section walks through how ARC implements each part on Kubernetes.

How Actions Runner Controller runs ephemeral runners on spot nodes

Actions Runner Controller (ARC) moves the scaling decision into Kubernetes. Each runner scale set has a listener that holds a long-poll session with GitHub. When a job targeting that scale set is queued, the controller creates a runner pod sized to the demand. The pod registers, takes exactly one job, and exits. The controller then deletes it rather than reusing it.

GitHub (queued jobs)
        |
        v
  ARC listener + controller
        |
        v
  Runner scale set (min / max runners)
        |
        v
  Ephemeral runner pods  --pending-->  Cluster autoscaler
        |                                   |
        v                                   v
  Spot node pool (default)        On-demand node pool (fallback)

The pods are scheduled onto a spot-backed node pool using node selectors and tolerations. If no node has room, the pods stay pending, and the cluster autoscaler adds spot capacity. When the pods finish, idle nodes drain and are removed. This lets compute follow the job queue instead of a fixed fleet size.

Scale to zero vs a warm minimum

Setting the minimum to zero means you pay nothing when the queue is empty. The first job after an idle period pays twice, though: once for node provisioning and once for image pulls. A small warm minimum keeps a few registered pods ready, which removes that delay for the cost of a few idle runners. Decide per scale set, based on how latency-sensitive that queue is.

Why ephemeral is the default

A runner that is reused keeps whatever the previous job left behind: credentials, modified toolchains, and poisoned caches. An ephemeral pod starts clean every time. That isolation is the reason ephemeral is the default. It also suits spot capacity, because an interrupted pod loses only its own job and never shared state.

+-----------------------------------+         +----------------------+
| ARC listener (one per scale set)  |<------->| GitHub               |
| long-poll session for job demand  |  long-  | queued workflow jobs |
+-----------------------------------+  poll   +----------------------+
                  |
                  v
+-----------------------------------+
| ARC controller                    |
| creates and deletes runner pods   |
+-----------------------------------+
                  |
                  v
+-----------------------------------+
| Runner scale set                  |
| min runners (0 or warm) / max     |
+-----------------------------------+
                  |
                  v
+-----------------------------------+         +----------------------+
| Ephemeral runner pods             |-------->| Cluster autoscaler   |
| one job each, then exit + delete  | pending | adds/removes nodes   |
+-----------------------------------+  pods   +----------------------+
          |                    |                         |
          | default            | fallback                |
          v                    v                         |
+---------------------+ +---------------------+          |
| Spot node pool      | | On-demand node pool |<---------+
| tolerations match   | | short or reclaimed  |  grows/shrinks both
+---------------------+ +---------------------+  pools

ARC's listener long-polls GitHub for queued jobs. The controller then creates one ephemeral runner pod per job within the scale set's min/max bounds. Pending pods prompt the cluster autoscaler to add nodes, preferring the spot pool and using on-demand as a fallback.

Where the hidden self-hosted costs sit

But the spot price on the node is only one line of the bill. A per-minute comparison with hosted runners leaves out everything that surrounds the runner.

Cost Why it is easy to miss
Control plane and system nodes The cluster fee, the controller and always-on system nodes bill even when no jobs are queued
Persistent volumes and caches Cache and registry disks bill per GB-month whether or not anything reads them
Container image pulls Every fresh spot node pulls large runner images, which costs bandwidth and adds startup minutes
NAT and data egress Dependency downloads and artifact uploads from private subnets pay per-GB processing
Logging and monitoring Ephemeral churn multiplies log streams and metric series
Team time On-call, controller and cluster upgrades, and toolchain patching

The last row is the structural one. Self-hosting turns a billed service into a system you own. Patching compilers, keeping runner images consistent with what developers expect, and answering pages for stuck queues all land on your backlog. On a shared cluster, splitting node, NAT and log spend between CI and its neighbours requires strict labelling. Without it, teams estimate their savings instead of measuring them.

Illustrative estimate (assumed numbers, not a benchmark)

Monthly cost = spot compute + fixed overhead + (engineer hours × hourly rate)

  • Modest volume: $400 + $300 + (20 h × $100) = $2,700
  • High volume: $6,000 + $1,200 + (30 h × $100) = $10,200

At modest volume, engineering time is most of the total, and hosted runners often win. At high volume, the fixed overhead and staff hours are spread across many more jobs, so the lower compute rate starts to decide the outcome.

Implementing a hybrid placement strategy with runner labels

Placement is decided per job with runs-on. With Actions Runner Controller, the value is the runner scale set's installation name, so two scale sets cover most needs: one scheduled onto a spot node pool and one onto an on-demand pool. Name them after their purpose, for example ci-spot and ci-ondemand, so workflow authors choose an interruption tolerance and not an instance type.

What goes where

Send lint, unit tests, and compile or package builds to ci-spot. These jobs are frequent and short, and a rerun produces the same result, so losing a node costs a few minutes of compute and nothing else. Keep release tagging, artifact publishing, deploys, database migrations, and long integration suites on ci-ondemand or on hosted runners. If one of these is interrupted halfway, you can end up with a partial release or a half-applied migration. A long job also has more time to be hit by an interruption, and each interruption throws away more completed work.

Making spot tolerable

  • Diversify capacity. Allow several instance types of similar size across multiple availability zones. Use capacity-optimized allocation where the provider offers it, so nodes come from the pools least likely to be reclaimed.
  • Keep jobs short and idempotent. Split long suites into parallel shards. Avoid side effects outside the workspace, so a rerun is safe.
  • Retry interruption failures. GitHub Actions does not retry failed jobs automatically. Rerun failed jobs with gh run rerun --failed, or automate the rerun for jobs that ran on spot runners.
  • Keep a fallback. When spot capacity is scarce, jobs sit queued. Let the node autoscaler fall back to on-demand capacity rather than block merges.
name: ci
on:
  push:
    branches: [main]
  pull_request:

jobs:
  lint:
    runs-on: ci-spot            # short and retryable: spot-backed scale set
    timeout-minutes: 10         # cap exposure to an interruption
    steps:
      - uses: actions/checkout@v4
      - run: make lint

  unit-tests:
    runs-on: ci-spot
    timeout-minutes: 15
    strategy:
      fail-fast: false          # one reclaimed node should not cancel other shards
      matrix:
        shard: [1, 2, 3, 4]     # split the suite so each shard stays short
    steps:
      - uses: actions/checkout@v4
      - run: make test SHARD=${{ matrix.shard }} TOTAL_SHARDS=4

  build:
    runs-on: ci-spot
    needs: [lint, unit-tests]
    steps:
      - uses: actions/checkout@v4
      - run: make package       # writes only to the workspace, so a rerun is safe
      - uses: actions/upload-artifact@v4
        with: { name: package, path: dist/ }

  deploy:
    runs-on: ci-ondemand        # non-retryable side effects: on-demand scale set
    needs: build
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    environment: production
    concurrency: { group: deploy-production, cancel-in-progress: false }
    steps:
      - uses: actions/download-artifact@v4
        with: { name: package, path: dist/ }
      - run: make deploy

A workflow that sends lint, sharded unit tests, and the build to the ci-spot runner scale set, and runs the production deploy on the ci-ondemand scale set with a concurrency guard so deploys never overlap.

Cutting cold starts and cache misses on ephemeral runners

An ephemeral runner starts with nothing, and that blank state is where most of the lost time goes. A fresh spot node has to boot, join the cluster, pull the runner image, and then fetch every dependency the job needs. On a busy morning that can add minutes to every job before a single test runs.

Pre-bake node images. Build node images that already contain the runner image and your common build images. A pre-pulled multi-gigabyte toolchain image turns a cold pull into a local disk read.

Mirror the registry. Run a pull-through cache or registry mirror inside the cluster's network so that repeated pulls stay local. This cuts pull latency and the cross-region or internet egress you pay for on every new node.

Keep dependency caches near the cluster. Store package and build caches in object storage in the same region, or in a remote build cache your tooling supports. Without this, every ephemeral job re-downloads dependencies from the internet. Builds get slower than on a long-lived runner, and egress charges grow with job count.

Right-size pod requests. Inflated requests block bin-packing. If a runner pod requests 4 CPU and 8Gi of memory but uses 1.5 CPU, each node fits fewer jobs and the autoscaler adds nodes you don't need. Set requests near observed usage, for example 2 CPU and 4Gi, and set a memory limit to contain runaway builds.

Scale-to-zero vs. warm capacity. Scaling to zero minimizes idle spend but makes every first job pay the full cold-start cost. A small warm pool of runners during working hours costs a predictable amount and removes that wait for the jobs developers are actively watching.

apiVersion: v1
kind: Pod
metadata:
  name: ci-runner-example
  labels:
    app: ci-runner
spec:
  restartPolicy: Never            # ephemeral: one job, then the pod is gone
  containers:
    - name: runner
      image: ghcr.io/actions/actions-runner:latest  # pin a tag or digest in practice
      # Use the copy pre-baked into the node image instead of re-pulling
      imagePullPolicy: IfNotPresent
      resources:
        requests:
          cpu: "2"                # near observed peak (~1.5 CPU), not a guess of 4
          memory: 4Gi
          ephemeral-storage: 20Gi # workspace + local layer cache; avoids disk-pressure evictions
        limits:
          memory: 4Gi             # contain runaway builds; OOM-kill one job, not the node
          ephemeral-storage: 30Gi
          # No CPU limit: avoids throttling during bursty compile steps.
          # The CPU request still governs scheduling and bin-packing.

A Kubernetes pod spec for an ephemeral runner. CPU and memory requests are set close to observed usage so jobs pack densely onto spot nodes, a memory limit contains runaway builds, and IfNotPresent reuses the runner image pre-baked into the node.

How self-hosted spot runners can save engineering time

Self-hosting adds operating work, so it saves engineering time only when it removes more waiting and friction than it creates. These are the places where it can do that:

  • Shorter feedback loops. Developers wait on CI many times a day. Runners sized for the workload, with pre-baked images and caches close to the cluster, can shorten job duration and queue time, and that time comes back to everyone who pushes code. Spot pricing makes larger machines affordable for frequent jobs that would be costly on large hosted runners.
  • Fewer flaky, environment-specific failures. One controlled runner image, versioned like any other artifact, means developers stop debugging differences between CI and their own toolchains. Ephemeral pods also rule out failures caused by leftover state from a previous job.
  • Direct access to internal resources. Runners inside your network can reach private registries, test databases, and internal services without tunnels or allowlists maintained per job.
  • Less hand-tuning of capacity. ARC and the autoscaler scale with the queue, so nobody resizes a fixed runner fleet or manually restarts stuck long-lived runners.
  • Safe retries instead of investigations. Short, idempotent jobs on spot capacity turn an interruption into an automatic rerun rather than a ticket.

Illustrative estimate (assumed numbers, not a benchmark). Assume 40 developers, each waiting on 6 CI runs per day, 20 working days per month. If faster runners and warm caches cut 2 minutes of blocking wait per run, the time recovered is 40 × 6 × 20 × 2 = 9,600 minutes, or 160 hours per month. That figure has to be weighed against the 20–30 engineer hours of platform upkeep assumed in the hidden-costs estimate. Measure your own queue time and job duration before relying on a number like this. If the reduction in wait time does not show up in those metrics, the time savings are not real.

Self-hosting costs engineering time when the platform team is small, when nobody already runs Kubernetes, or when CI volume is too low for faster jobs to add up. In those cases, hosted runners save more time because nobody operates them.

Security requirements before self-hosting

A self-hosted runner executes arbitrary code from whoever can trigger a workflow, inside your network, with whatever permissions the host has. GitHub-hosted runners give each job a fresh VM that is discarded afterwards, outside your network. Self-hosting removes that isolation, so you have to rebuild it yourself. The following requirements are not optional.

Never run untrusted fork pull requests

On a public repository, anyone can open a pull request that edits the workflow or the build scripts. If that job lands on a self-hosted runner, an outsider is running code on your infrastructure. Route fork PRs to hosted runners, or require maintainer approval before any self-hosted job starts.

Scope runner groups to specific repositories

A runner group open to every repository in the organization lets a low-trust repo, such as an intern sandbox or an archived experiment, reach the same nodes that build production artifacts. Restrict each group to the repositories that need it.

Use workload identity, not static keys

Long-lived cloud keys stored as secrets or baked into node images can be exfiltrated by any job and stay valid after that. Use OIDC federation so each job receives short-lived, narrowly scoped credentials. Also block pod access to the node's instance metadata endpoint. Otherwise every job inherits the node's IAM role, and from there it can reach internal databases, registries, and other services in the VPC.

Use ephemeral runners

A persistent runner keeps files, Docker layers, background processes, and cached credentials between jobs. One malicious job can plant something that the next job, possibly a release build, picks up. Ephemeral runners take exactly one job and are then destroyed, so nothing carries over from one job to the next.

Measuring results and deciding: hosted, self-hosted spot, or hybrid

Capture a baseline on hosted runners for at least a few weeks before migrating, then collect the same metrics afterward:

  • Queue time (p50 and p95): spot capacity shortages appear here first.
  • Job duration: cold caches and smaller nodes can erase per-minute savings.
  • Interruption and retry rate: every retried job is paid for twice.
  • Node utilization: idle warm pools are spend that produces no builds.
  • Cost per successful job, by workflow: a cheap average can hide one expensive integration suite.

Choosing a model

Hosted runners usually win for low or spiky volume, small teams, and teams with no platform capacity. In those cases you pay only for minutes used, and nobody gets paged when a runner breaks. Self-hosted spot pays off with sustained high volume, large or specialized machines such as GPUs or high-memory builds, and teams that already operate Kubernetes. Those teams have already absorbed the cluster's fixed costs.

Option Cost Ops burden Latency Security
Hosted standard Moderate per minute None Low, predictable Vendor-isolated VMs
Hosted larger High per minute None Low Vendor-isolated VMs
Self-hosted on-demand Lower at high volume High Scaling-dependent Your responsibility
Self-hosted spot Lowest at high volume Highest Interruption risk Your responsibility
Hybrid Tuned per workflow Medium to high Mixed Split by label

These inputs drift over time. Hosted pricing changes, spot prices and interruption rates shift, and workflow volume grows. Re-run the model quarterly, and also after any major pipeline change.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to self-hosted github runners cost, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.