# Migrate from self-hosted GitHub Actions runners without a rewrite

> Migrate from self-hosted GitHub Actions runners without a rewrite: the checklist, the runs-on diff, and the steps that only worked on your own box.

Source: https://latchkey.dev/learn/runners/migrate-from-self-hosted-runners  
Updated: 2026-09-20

Most teams looking to migrate from self-hosted GitHub Actions runners in 2026 are reacting to a charge that never arrived: GitHub announced a $0.002 per minute Actions cloud platform charge on self-hosted usage from 1 March 2026, then postponed it the next day, and its billing page today still says "GitHub Actions usage is free for self-hosted runners". The reasons that survive the reprieve are the ones worth migrating for, and none of them is the meter.

The sequence matters, because the internet remembers the announcement and not the retraction. On 16 December 2025 GitHub published a changelog cutting hosted-runner prices "by up to 39%" from 1 January 2026 and adding a "$0.002 per minute GitHub Actions cloud platform charge" on self-hosted runners from 1 March 2026. The next day an update went to the top of the same post: "We're postponing the announced billing change for self-hosted GitHub Actions to take time to re-evaluate our approach." Read on 2026-09-20, the billing documentation still states that self-hosted usage is free.

So price is not the trigger any more, and that is clarifying. The costs that did not go away are the machine bill, the images you rebuild, the pool that does not scale at 09:00, the security surface of a runner that keeps state between jobs, and the pager. A managed runner buys those back and charges a meter for them.

The good news is that the workflow change is genuinely small. Self-hosted runners are selected by labels, managed runners are selected by labels, and everything above the `runs-on:` line stays as it is. The work is in the eight things below that the label was quietly providing.

## The pre-flight checklist

GitHub's own migration guide tells you to inventory machine specs, custom images, private network access and hard-coded labels. These eight are that list plus the ones that only show up once the runner is ephemeral.

- **Labels.** A self-hosted runner gets `self-hosted` plus an OS label (`linux`, `windows`, `macOS`) and an architecture label (`x64`, `ARM`, `ARM64`) automatically, and `runs-on: [self-hosted, linux, ARM64]` matches cumulatively. Grep for `self-hosted` and for every custom label you added.
- **Runner groups.** `runs-on: { group: build-runners }` and the `group` plus `labels` form both disappear with the runners. Each one needs a plain label on the other side.
- **Runner sizes.** Write down the vCPU, memory and disk of the machines you actually run. A job that has been enjoying 16 vCPU and 200 GB of disk for two years will not say so until it lands on a 2 vCPU runner with 43 GB free.
- **Secrets.** Anything the job read from the host, an SSH key in `~/.ssh`, a cloud credential file, a `.npmrc` baked into the image, becomes a repository or environment secret. This is the single largest source of first-run failures.
- **Cache keys.** A self-hosted runner with a persistent disk made `actions/cache` optional, because the working directory survived. On ephemeral runners the cache is the only thing that survives, so every dependency directory that was implicitly warm now needs a cache step with a real key.
- **Matrix.** A matrix sized to your pool is now sized to a plan ceiling: 20, 40, 60 or 500 concurrent jobs on Free, Pro, Team and Enterprise, and 5 concurrent macOS jobs below Enterprise.
- **Artifacts.** Jobs that passed files by writing to a shared path on the host need `actions/upload-artifact` and `download-artifact`, because the next job is on a different machine.
- **Concurrency.** A `concurrency:` group that existed to stop two jobs fighting over one runner's disk can usually go, since every job now gets its own machine.

## The before and after

One line per job, and the label array collapses to a single label. Keep the old array in a comment until the pool is drained, because that is the cheapest possible rollback.

```.github/workflows/ci.yml
jobs:
  build:
    # before
    # runs-on: [self-hosted, linux, x64, build-large]
    # after
    runs-on: latchkey-medium          # 4 vCPU, 16 GB
    steps:
      - uses: actions/checkout@v7
      - uses: actions/cache@v6         # was implicit: the disk persisted
        with:
          path: ~/.cache/pip
          key: pip-${{ hashFiles('requirements.txt') }}
      - run: pip install -r requirements.txt && pytest
```

## The steps that only worked because the runner was yours

This is where self-hosted migrations fail, and every one of these is findable before you cut over rather than after. Read the failing job list from your first dry run against it.

- Anything reaching a private network: an internal package registry, a database, an on-premises service. A managed runner has no route to it unless you build one.
- Anything installed once on the host: a compiler, a licensed toolchain, a browser build, a CUDA driver. On an ephemeral runner it is installed per job or it is not there.
- Anything that assumed state between jobs: a warm Docker layer store, a Gradle daemon, a node_modules directory left behind by the previous run.
- Anything writing outside the workspace, to `/opt`, `/var` or a mounted volume that only exists on your machines.
- Anything relying on a fixed egress IP for an allowlist. Managed runners change addresses; several vendors sell static IPs as an add-on.

## What changes in cache behavior

Self-hosted runners hid the cache problem. If the machine kept its disk, the second run found the dependencies already there and `actions/cache` was decoration. Ephemeral runners delete everything between jobs, so the cache stops being an optimization and becomes the mechanism.

The limits are then real for the first time: 10 GB per repository, entries removed after 7 days without a hit, and restore scoped to the writing branch or the default branch. Expect the first few runs after a cutover to be slower than the self-hosted baseline and to stay slower until the cache is warm, and do not read that first number as the answer.

> Docker is the specific case worth planning. A self-hosted runner that had every base image already pulled now pulls them on every job, which is also how a migrated pipeline meets [the Docker Hub rate limit](/learn/failures/docker-hub-pull-rate-limit-in-ci) for the first time.

## What to test first

Run one job on both, at the same time, on the same commit. A self-hosted pool does not have to be drained to be compared, and a side-by-side answers the size question that no rate card can.

1. Pick the job with the most minutes and add a second job to the same workflow with the new label, running the same steps.
2. Compare wall clock and read the gap as a size question first. A job that is twice as slow is usually on half the vCPU, not on a worse runner.
3. Look for the private-network and installed-tool failures in the new job's log. These fail loudly and early, which is what you want.
4. Warm the cache with three or four runs before you compare timings seriously, then cut over one workflow at a time and drain the pool last.

## If you are moving to Latchkey

Latchkey runners are managed and ephemeral, one job per runner, at $0.0025 a minute for 2 vCPU with 8 GB against GitHub-hosted at $0.006, with a dependency cache that is a one-line swap for `actions/cache` and 2,000 to 6,000 free minutes a month by plan. The concessions that matter to a self-hosted team: Linux x86_64 only, sizes stop at 16 vCPU, there is no route to your private network, and neither a concurrency ceiling nor a cache size is published. [GitHub Actions runner alternatives](/learn/runners/github-actions-runner-alternatives) prices the vendors that do run inside your own cloud account, which is the closer analogue to what you have today.

What you get back is the half of self-hosting that has no rate card: a transient failure is diagnosed and retried inside the run instead of paging whoever owns the pool. The `latchkey-small` shape below was recorded on 2026-09-20 in job `cli-02917f33-a216-4255-9f21-7043ddccad19`, so you can compare it against the machines you are retiring before you change a label.

```latchkey run --no-context, job cli-02917f33
label=latchkey-small
os=Ubuntu 24.04.4 LTS
arch=x86_64
kernel=6.17.0-1019-aws
vcpu=2
mem_total_mb=7734
disk_avail=43G
runner_user=runner
docker=Docker version 29.7.2, build a7dcaa6
node=v20.20.2
utc=2026-09-20T09:18:04Z
```

## FAQ

### Per minute charges for self-hosted runners?

Announced and then postponed. GitHub's 16 December 2025 changelog added a "$0.002 per minute GitHub Actions cloud platform charge" on self-hosted usage from 1 March 2026, and an update at the top of the same post says the billing change is postponed. Read on 2026-09-20, the billing documentation still states that GitHub Actions usage is free for self-hosted runners.

### Is the GitHub Actions control plane no longer free for self-hosted runners?

It is still free today. The charge that would have made it paid was announced on 16 December 2025 for 1 March 2026 and postponed the following day, and GitHub has not published a replacement date. Plan on the documented position rather than the announcement, and note that the hosted-runner price cut of up to 39% that shipped alongside it did take effect on 1 January 2026.

### When should you use self-hosted runners vs GitHub-hosted runners?

Self-hosted wins on three things a meter cannot price: a route to your private network, hardware nobody rents you, and a fixed egress address for an allowlist. If none of those is your reason, you are paying an operations bill for compute you could rent, and the honest test is what your pool costs in engineer hours per month rather than in instance hours.

### Why do jobs sit waiting for a runner to pick them up?

On self-hosted runners it means no online runner carries every label in the job's `runs-on`, or the pool is fully busy, or the autoscaler has not caught up. The labels accumulate, so `runs-on: [self-hosted, linux, ARM64]` needs one runner with all three. A managed runner removes the failure mode by provisioning per job rather than matching against a pool.

## References

- [GitHub changelog: 2026 Actions pricing and the postponed self-hosted charge (verified 2026-09-20)](https://github.blog/changelog/2025-12-16-coming-soon-simpler-pricing-and-a-better-experience-for-github-actions/)
- [GitHub Actions billing: self-hosted runner usage is free (verified 2026-09-20)](https://docs.github.com/en/billing/concepts/product-billing/github-actions)
- [Using self-hosted runners in a workflow: default labels and runner groups (verified 2026-09-20)](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/use-in-a-workflow)
- [Migrating from self-hosted runners to GitHub-hosted runners (verified 2026-09-20)](https://docs.github.com/en/actions/tutorials/migrate-to-github-runners)
- [GitHub Actions limits: concurrent jobs by plan (verified 2026-09-20)](https://docs.github.com/en/actions/reference/limits)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
