Migrate from self-hosted GitHub Actions runners without a rewrite
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-hostedplus an OS label (linux,windows,macOS) and an architecture label (x64,ARM,ARM64) automatically, andruns-on: [self-hosted, linux, ARM64]matches cumulatively. Grep forself-hostedand for every custom label you added. - Runner groups.
runs-on: { group: build-runners }and thegrouppluslabelsform 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.npmrcbaked 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/cacheoptional, 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-artifactanddownload-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.
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 && pytestThe 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,/varor 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.
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.
- Pick the job with the most minutes and add a second job to the same workflow with the new label, running the same steps.
- 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.
- 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.
- 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 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.
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:04ZFrequently asked questions
Per minute charges for self-hosted runners?
Is the GitHub Actions control plane no longer free for self-hosted runners?
When should you use self-hosted runners vs GitHub-hosted runners?
Why do jobs sit waiting for a runner to pick them up?
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.Related guides
References
- GitHub changelog: 2026 Actions pricing and the postponed self-hosted charge (verified 2026-09-20)
- GitHub Actions billing: self-hosted runner usage is free (verified 2026-09-20)
- Using self-hosted runners in a workflow: default labels and runner groups (verified 2026-09-20)
- Migrating from self-hosted runners to GitHub-hosted runners (verified 2026-09-20)
- GitHub Actions limits: concurrent jobs by plan (verified 2026-09-20)
- GitHub Actions documentation