Skip to content
Latchkey LogoLatchkey home

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.

A 10,000-minute month: $30 Latchkey, $60 GitHub-hosted, $80 Depot, against $480 of ARC time
Published rates for a 10,000-minute month against one labeled assumption about ARC upkeep. The assumption is yours to replace; the meters are not.

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 mode runs-on matches the installation name. Include reusable workflows and composite actions.
  • Runner groups. A scale set placed in a group through runnerGroup inherits 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 .npmrc baked 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 maxRunners plus 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: dind or kubernetes means your jobs use container: or services:. Both have working equivalents on a managed runner, and the failure mode if you forget is described below.
  • Custom certificates and proxies. githubServerTLS injecting 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.

.github/workflows/ci.yml
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 && pytest

What 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.

.github/workflows/integration.yml
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:integration

What 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.

  1. 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.
  2. 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.
  3. 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.
  4. Cut over one workflow at a time, then scale the ARC pool to zero by setting maxRunners and minRunners to 0 before you delete anything. That is a reversible pause; helm uninstall is 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?
The runner registers with a just-in-time token, accepts exactly one job, and is destroyed. Under ARC the controller creates a pod per job and deletes it afterwards, retrying pod creation up to five times if it fails. Managed vendors do the same thing with a virtual machine instead of a pod, which is why nothing survives between jobs on either and why the cache stops being optional.
Why do my ARC runners keep receiving a shutdown signal?
Usually because the pod went away underneath the job: a node scale-down, an eviction, a memory limit on the runner container, or a scale set upgrade rolling pods while jobs were running. It is the ephemeral-pod version of a runner going offline mid-job, and it is the failure mode that disappears with the cluster rather than one you can configure away.
Does Actions Runner Controller cost anything?
Not in fees. GitHub documents that Actions usage is free for self-hosted runners, and ARC is free software installed from a Helm chart. The costs are the cluster, the idle capacity that keeps queue times down, the image pipeline and the engineering hours. GitHub announced a $0.002 per minute cloud platform charge on self-hosted usage on 16 December 2025, for 1 March 2026, then posted an update to the same changelog postponing it, so the meter is still not there.
How do I migrate from self-hosted runners without rewriting workflows?
Change 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

No cluster, no listener, no chart upgrade: Latchkey runs the same jobs at $0.0025/min at 2 vCPU. Start free → 30-day trial · No credit card