Actions Runner Controller alternative: moving ARC off Kubernetes
Teams shopping for an Actions Runner Controller alternative are rarely unhappy with ARC itself, which does exactly what it says: a Kubernetes operator that scales ephemeral runner pods against your job queue, with no per-minute fee from GitHub or from anyone. They are unhappy with what it sits on, because the cluster, the images, the listener and the pager are all yours, and none of that appears on a rate card.

The workflow change is small. ARC selects runners with runs-on set to the Helm installation name of a runner scale set, a managed vendor selects runners with a label, and everything above that line in the workflow file stays as it is. The work is in the eight things below that your cluster was quietly providing, and in deciding which of them you actually still need.
It is worth being precise about what ARC costs, because the honest answer is not "more than a managed runner". GitHub documents that Actions usage is free for self-hosted runners, and ARC itself is free software. What you pay is the cluster, the node capacity that sits idle so a queue does not form, the image rebuilds, and the hours. The last of those is the one nobody bills you for and everybody spends.
This page is the ARC-specific version of the general move, which is worked through in migrate from self-hosted runners. Vendor rates and shapes are in GitHub Actions runner alternatives.
What ARC is doing for you right now
ARC installs as two Helm charts: a controller manager, by convention in an arc-systems namespace, and one runner scale set per pool, by convention in arc-runners. The scale set opens a long-polling HTTPS connection to the Actions service, waits for a job-available message, and patches an ephemeral runner set to the desired replica count. Each runner pod registers with a just-in-time token, takes exactly one job, and is deleted.
Two documented behaviors are worth carrying in your head before you change anything. If a runner pod fails to start, the controller retries it up to five times. If no runner accepts the job, the Actions service unassigns it after 24 hours, which is why a broken listener looks like a job that queues all afternoon rather than a job that fails. Managed runners remove both by provisioning per job instead of matching against a pool you own.
The pre-flight checklist
Nine items. The first six are the standard runner-migration list, and the last three exist only because the runners were pods in your cluster.
- Labels. Grep for the Helm installation name of every scale set, not for
self-hosted: in scale-set moderuns-onmatches the installation name. Include reusable workflows and composite actions. - Runner groups. A scale set placed in a group through
runnerGroupinherits that group's repository access. On the other side that becomes plain label access, so write down which repositories were allowed to use which pool. - Secrets. Anything mounted into the runner pod, a registry credential, a cloud key file, a
.npmrcbaked into your runner image, becomes a repository or environment secret. This is the single largest source of first-run failures. - OIDC. This one carries over unchanged, because the token is minted by GitHub rather than by the runner. What does not carry over is pod identity: a job that authenticated as the service account of its pod, through IRSA or Workload Identity, has no equivalent and needs an OIDC trust policy instead.
- Cache keys. They carry over as they are. What changes is the hit rate, if your runner image pre-baked dependencies that the cache now has to restore.
- Matrix and concurrency. Your ceiling was
maxRunnersplus whatever the cluster could schedule. On the other side it is the vendor's: GitHub allows 20 to 500 concurrent jobs by plan, Namespace publishes 32 to 160 Linux vCPUs, Depot and Blacksmith publish no ceiling at all. - Container mode.
containerMode: dindorkubernetesmeans your jobs usecontainer:orservices:. Both have working equivalents on a managed runner, and the failure mode if you forget is described below. - Custom certificates and proxies.
githubServerTLSinjecting an internal CA, or a proxy block, means jobs talk to something inside your network. A managed runner has neither, and that is usually the reason a migration stops. - Artifacts. Unchanged, but check for jobs that passed files through a shared persistent volume instead of
actions/upload-artifact, because the next job is no longer in your cluster.
The before and after
One line per job. Keep the old one commented for two weeks: rolling back to ARC should not require remembering which scale set a job used.
jobs:
build:
# before: the Helm installation name of the scale set
# runs-on: arc-runner-set
# after
runs-on: latchkey-medium # 4 vCPU, 16 GB
steps:
- uses: actions/checkout@v5
- uses: actions/cache@v4 # was: baked into the runner image
with:
path: ~/.cache/pip
key: pip-${{ hashFiles('requirements.txt') }}
- run: pip install -r requirements.txt && pytestWhat ARC actually costs, with the assumption written down
Here is an arithmetic you should redo with your own numbers rather than believe. Assume one engineer spends four hours a month on chart upgrades, runner image rebuilds, listener incidents and node capacity, at a fully loaded $120 an hour. That is $480 a month, before the cluster bill, and it is the number a rate card cannot show you.
Against it, a 10,000-minute month of Linux CI costs $25.00 on Latchkey at $0.0025 a minute plus a plan fee from $5, so $30.00 with the included 2,000 minutes not netted off; $60.00 on GitHub-hosted at $0.006; and $60.00 plus a $20 plan fee on Depot. If your four hours are really one hour, the comparison narrows sharply, and if you run 200,000 minutes a month it reverses. The only vendor claim published in this area is WarpBuild's, on warpbuild.com/pricing read 2026-09-20, which guarantees a 40% infrastructure saving against ARC on Kubernetes without publishing the arithmetic behind it.
Container jobs are the sharp edge
In kubernetes container mode, ARC runs each container job as a separate pod and refuses jobs that have no job container at all, with a message that says jobs without a job container are forbidden on this runner. Teams work around it by setting ACTIONS_RUNNER_REQUIRE_JOB_CONTAINER to false. If you see that variable in your values file, you have jobs of both kinds and the migration has to check both.
On a managed runner both kinds simply run, because Docker is present on the host rather than arranged by the controller. The measured runner behind that sentence is a latchkey-medium, Ubuntu 24.04.4 with Docker 29.7.2, quoted in full in migrate from Blacksmith. What you lose along with dind is the privileged pod it required, which is a security improvement rather than a regression.
jobs:
integration:
runs-on: latchkey-medium
container: node:22-bookworm # was: containerMode dind or kubernetes
services:
postgres:
image: postgres:17
env:
POSTGRES_PASSWORD: ci
steps:
- uses: actions/checkout@v5
- run: npm ci && npm run test:integrationWhat to test first
Run both at once. An ARC pool does not have to be drained to be compared, and a job that runs on the old and the new label in the same workflow answers the size question that no rate card can.
- Duplicate your heaviest job with the new label and leave the ARC copy in place. Compare wall clock on the same commit, not against last week.
- Read the first failures as an inventory rather than a verdict. Private network, internal CA and pre-baked tool failures all surface in the first two runs, and each one is a decision about whether that job can move at all.
- Warm the cache over three or four runs before you compare timings seriously. A pool with a persistent disk was hiding how much your build depends on a warm cache.
- Cut over one workflow at a time, then scale the ARC pool to zero by setting
maxRunnersandminRunnersto 0 before you delete anything. That is a reversible pause;helm uninstallis not.
When staying on ARC is the right answer
Four cases, and they are not close calls. A job that reaches a private network, an internal registry or an on-premises database. A job that needs an internal CA in its trust store. A job that needs hardware nobody rents you, or a fixed egress address that an allowlist depends on. And the volume case: past a few hundred thousand minutes a month, a cluster you already run and already staff is cheaper than any meter here.
What Latchkey adds if none of those applies: managed Linux x86_64 runners at $0.0025 a minute for 2 vCPU with 8 GB and $0.005 at 4 vCPU, one job per runner, with a transient failure such as a registry rate limit diagnosed and retried inside the run. What it does not have, against a cluster you control: arm64, Windows, macOS, sizes above 16 vCPU, a route into your VPC, or a published concurrency ceiling.
Frequently asked questions
How do ephemeral GitHub Actions runners work?
Why do my ARC runners keep receiving a shutdown signal?
Does Actions Runner Controller cost anything?
How do I migrate from self-hosted runners without rewriting workflows?
runs-on and nothing else, which works because both sides select runners by a label. The rewrite risk is not in the YAML, it is in the assumptions: pre-baked tools, a private network route, a persistent disk, pod identity for cloud auth. Inventory those first, move the heaviest job, and keep the old pool scaled to zero rather than deleted until the rest follows.Related guides
References
- Actions Runner Controller: the operator, the listener and ephemeral runner pods (verified 2026-09-20)
- Deploying runner scale sets with ARC: installation names, runner groups, container modes (verified 2026-09-20)
- GitHub Actions billing: self-hosted runner usage is free (verified 2026-09-20)
- GitHub Actions limits: concurrent jobs by plan (verified 2026-09-20)
- GitHub changelog: the $0.002 self-hosted charge announced for 1 March 2026 and postponed (verified 2026-09-20)
- Kubernetes documentation
- GitHub Actions documentation