On this page · 10 min
What a self-hosted runner is, and why your jobs start cold
A runner is the machine that executes a GitHub Actions job. By default your workflows run on GitHub-hosted runners, where GitHub gives each job a new virtual machine (its single-CPU runners use a container instead). A self-hosted GitHub Actions runner is a machine you run yourself, with GitHub's open-source runner app on it. The app connects out to GitHub over HTTPS on port 443, picks up jobs whose labels match, and runs them. GitHub supports this for a repository, an organization or an enterprise, and the machine can be a cloud VM, a container, a server in your rack or the laptop on your desk, as GitHub's runner docs describe.
A new VM per job is clean, but it's also cold. Take a typical Node job: before the first test runs, it downloads a cache archive of npm packages, runs npm ci against the network anyway because the cache key changed, and pulls two Docker images. With a small test suite, that setup can take longer than the tests. None of that setup depends on your change, and the previous job did exactly the same work.
So "warm" means the runner already has your toolchain, package caches and images on disk when the job starts. The hard part is getting that while still giving each job a clean machine.
Warm or clean: four ways to run runners
Each approach decides where the warm state lives and whether jobs share it.
| Approach | Where warm state lives | Jobs share a machine? | Scales by | Best at |
|---|---|---|---|---|
| GitHub-hosted | Cache archives you download each job | No | GitHub | Zero upkeep, macOS and Windows, public repos |
| One persistent runner | The runner's own disk | Yes, one job at a time | Adding more runners by hand | One trusted repo with low volume |
| Ephemeral on Kubernetes or autoscaled VMs | The container or VM image, if you bake it in | No | Pods or VMs per queued job | Teams already running Kubernetes or AWS |
| Ephemeral from a warm snapshot | A saved machine, copied per job | No | Machines per queued job | Jobs that spend more time on setup than on tests |
A persistent runner stays warm because nothing is ever cleaned up, so one job's leftovers become the next job's environment. A flaky test can poison the machine for everyone, and jobs queue behind each other. GitHub recommends self-hosted runners only with private repositories, because a pull request from a fork can run code on the machine.
Ephemeral runners avoid that. An ephemeral runner takes one job, then GitHub removes its registration, so you throw the machine away and start the next job on a new one. GitHub recommends ephemeral runners for autoscaling. Actions Runner Controller (ARC), GitHub's Kubernetes operator, runs them as containers, and RunsOn says it gives each job a fresh EC2 instance in your AWS account. They're clean, and they scale well if you already run that infrastructure. A new container or VM is cold unless you bake the caches into its image, which works: you rebuild the image with a Dockerfile or an image builder when dependencies change, and every job starts from it.
A warm snapshot is the same idea applied to a running machine. A snapshot is a saved copy of a whole machine, disk and memory, that new machines start from, so services such as the Docker daemon are already running when a job begins. You still refresh it when dependencies change, but by running commands on a live machine and saving it again rather than through an image build. Every job gets its own copy, so the warm state is copied rather than shared. Whether this beats a baked image comes down to how quickly your platform starts a machine from it. You can do this on boxd or on any VM platform that can start machines from a snapshot; the steps below use boxd commands, and the runner side is the same anywhere.
What you'll have at the end, and what you need
At the end, each job labelled warm-linux runs on its own fresh machine that already has the runner app, your toolchain and your package caches, and the machine is deleted when the job finishes.
You need:
- Admin rights on a GitHub organization, and the
ghCLI logged in on the computer that launches runners. - A private repository with a workflow you want to speed up.
- The boxd CLI, or another VM platform that can save and start snapshots.
Set up a self-hosted GitHub Actions runner that starts warm
1. Create the base machine
boxd new runner-base --vcpu 4
boxd connect runner-baseThe first command creates a machine with 4 vCPUs and 16 GiB of RAM, and the second opens a shell on it. Pick the size now: machines started from a snapshot keep the size of the machine it was saved from. Sizes go from 1 vCPU and 4 GiB to 8 vCPUs and 32 GiB, as the resources page lists.
2. Install the runner app, but don't register it
mkdir ~/actions-runner && cd ~/actions-runner
curl -O -L https://github.com/actions/runner/releases/download/v2.338.2/actions-runner-linux-x64-2.338.2.tar.gz
tar xzf ./actions-runner-linux-x64-2.338.2.tar.gz
sudo ./bin/installdependencies.shThis downloads the runner (v2.338.2 was the latest release in November 2026; use the newest one), unpacks it and installs the libraries it needs. Skip config.sh. Registration happens per job in step 5, so the snapshot holds no runner identity and no token.
3. Warm the caches
Install what your workflow installs, and fill the caches it reads. For a Node project:
gh auth login # only to clone; logged out below
gh repo clone your-org/your-app ~/warm && cd ~/warm
npm ci # fills ~/.npm, npm's package cache
docker pull node:22-bookworm # images stay in the local image store
cd ~ && rm -rf ~/warm && gh auth logoutWarm the package manager's cache in your home directory, not a checkout inside the runner's _work folder. actions/checkout runs git clean -ffdx before fetching by default, so a node_modules folder you pre-filled there would be deleted, while ~/.npm is left alone.
The snapshot holds this machine's disk and memory, and every job machine gets a copy, along with the rights of the user the runner runs as. Clone with a short-lived, read-only token, and before the next step remove anything else that holds a secret: registry tokens in ~/.npmrc, docker login credentials, git credential helpers.
4. Save the snapshot
Exit the shell, then on your own computer:
boxd snapshots save runner-base runner-warmThis saves the machine's memory and disk as a snapshot named runner-warm while the machine keeps running. Saving again under the same name replaces it, so keep runner-base around: to refresh the caches later, update it and save again. The snapshots guide covers versions and regions.
5. Start one job's runner
Save this as launch-runner.sh on the computer that launches runners:
#!/usr/bin/env bash
set -euo pipefail
ORG=your-org
NAME="warm-$(date +%s)-$RANDOM"
# The organization's default runner group
GROUP_ID=$(gh api orgs/$ORG/actions/runner-groups \
--jq '.runner_groups[] | select(.default) | .id')
# A one-job runner registration, valid for this runner only
JIT=$(gh api -X POST orgs/$ORG/actions/runners/generate-jitconfig \
-f name="$NAME" -F runner_group_id="$GROUP_ID" -f 'labels[]=warm-linux' \
--jq .encoded_jit_config)
boxd new "$NAME" --from-snapshot runner-warm --delete-after 2h
boxd machine exec "$NAME" -- "cd ~/actions-runner && ./run.sh --jitconfig $JIT"
boxd machine remove "$NAME" -yThe script asks GitHub's just-in-time runner API for a registration that's good for one runner and one job. It starts a machine from the snapshot, runs the runner with that registration, and deletes the machine when the runner exits. Run bash launch-runner.sh and you should see Connected to GitHub and Listening for Jobs, then the job's name when one arrives. In GitHub, the runner shows under Settings, Actions, Runners while it lives.
Because of the JIT registration, the token that can create runners stays on the launcher, and the job machine only ever sees its own one-job registration. Set --delete-after above your longest job timeout: it deletes the machine whatever it's doing, so it cleans up after a crashed script but would also cut off a job that runs past it.
Starting a machine per job is only worth it if the start is short. In our benchmark runs with the open-source aisandboxbench harness in November 2026, a boxd machine started from a snapshot in a median 0.46 s in Frankfurt and 0.73 s in New York. After that the runner connects to GitHub and is assigned the job like any other runner. The disk is copy-on-write, so the caches aren't copied for each job, and a job machine stores only what it writes.
6. Point a workflow at it
jobs:
test:
runs-on: warm-linux
steps:
- uses: actions/checkout@v4
- run: npm ci --prefer-offline
- run: npm testruns-on: warm-linux sends the job to a runner with that label. npm ci still rebuilds node_modules, but from ~/.npm on disk; add --prefer-offline so npm skips checking the registry for packages it already has. Steps that only restore cache archives can go, because the cache is already there.
7. Start a runner for every queued job
GitHub sends a workflow_job webhook when a job is queued, with the job's labels. Run a small always-on service that receives it and starts launch-runner.sh in the background once for each queued job whose labels include warm-linux; each launch holds a process for as long as its job runs, so this belongs on a server, not a laptop. Any idle one-job runner with a matching label takes the next job, so one launch per queued job keeps the counts even.
Two cases need handling. Webhook deliveries can fail, so also poll for queued jobs every minute or so and launch for any you missed. And a job cancelled before it starts leaves an idle runner behind, so remove runners that have been idle for a few minutes. If you run Kubernetes, ARC does all of this for you, with containers instead of snapshots.
How to choose
- Stay on GitHub-hosted runners if your repository is public, you need macOS or Windows, or your jobs spend most of their time in tests rather than setup. There's no infrastructure to run.
- Use one persistent runner for a single private repository with a few jobs a day, where you trust every contributor and can live with jobs waiting in line.
- Use ARC if you already run Kubernetes and your jobs fit in containers. It's GitHub's own answer to scaling, and it starts and removes runners for you.
- Use a managed provider such as RunsOn if you want ephemeral runners in your own AWS account without building the launcher yourself.
- Use warm snapshots if setup time dominates your jobs and you want each job on its own VM. boxd fits here because a machine starts from a snapshot in under a second, so a runner per job costs little more than the job itself.
- Use boxd's own CI if you're willing to leave workflow files behind. The CI runner recipe runs your checks on machines started from a snapshot of the last green run on
main, but Marketplace actions don't work there because steps are plain commands. The CI use-case page has its benchmarks against GitHub-hosted runners.
What to do next
Time one workflow before and after, from push to green check, so you know what the snapshot saved. Re-save the snapshot when a lockfile changes or once a week, because a stale cache drifts back toward a cold start. And run a cold build every so often: a warm machine can hide a dependency you removed from package.json that's still in the cache. The boxd command reference lists every flag used above.
Install the boxd CLI and build your first warm runner:
curl -fsSL https://boxd.sh/downloads/install.sh | shFrequently asked
- What is a self-hosted runner in GitHub Actions?
- It's a machine you run yourself, with GitHub's open-source runner app on it. The app connects out to GitHub over HTTPS, picks up jobs whose labels match, and runs them. It can be a cloud VM, a container, a server or your own laptop, at repository, organization or enterprise level.
- Where do GitHub workflows run by default?
- On GitHub-hosted runners. Apart from its single-CPU runners, GitHub gives each job a new virtual machine, which is clean but has to install your toolchain and restore caches every time.
- Can I run GitHub Actions runners locally?
- Yes. Any machine that can make outbound HTTPS connections on port 443 can run the runner app, including a laptop or a server on your network. GitHub recommends self-hosted runners only for private repositories, because pull requests from forks can run code on the machine.
- How do I create a self-hosted runner?
- In the repository or organization settings, open Actions, then Runners, then New self-hosted runner, and run the download and config commands it shows. For runners that take one job each, ask GitHub's just-in-time runner API for a registration and start the runner with ./run.sh --jitconfig.
- How do I keep self-hosted runners warm without sharing state between jobs?
- Install the runner app, toolchain and package caches on one machine, save it as a snapshot, and start a fresh copy for every job as a one-job runner. Each job gets warm caches on a clean machine, and the copy is deleted afterwards.



