A 25-minute GitHub Actions pipeline is usually four delays stacked: jobs waiting on each other, work that didn't need to run, setup that a blank VM repeats on every job, and CPU-bound steps squeezed onto 2 cores (a private repository's ubuntu-latest has 2 CPUs and 8 GB). Take them in this order. First, measure the critical path, with queue time apart. Then skip what didn't need to run, run independent jobs at once, stop rebuilding state, give the heavy steps more cores, and shard what is still long. The first three are workflow fixes that cost nothing. The rest are the machine: a runner that keeps its caches between jobs and has more cores.
Skipbox is that runner. Your jobs run on your account's own machine, which keeps Docker layers, package caches, tools and build output between jobs, with 4 to 16 vCPU, for a flat $39 a month with unlimited minutes. On astro's CI that was 528 s on GitHub and 30 s on Skipbox. Moving is one line: runs-on: skipbox.
- The levers in order: measure, skip, run in parallel, stop rebuilding, add cores, shard
- Your own machine keeps caches and build output between jobs: astro's CI went from 528 s to 30 s
- 20,000 minutes a month on 4 vCPU: $222 on GitHub, $39 on Skipbox
1. Measure the critical path
The time your team waits is the chain of jobs that finishes last, not the sum of every job. Open a representative pull request's run: the graph shows which jobs wait on which, and each job's log shows its step times. Write down, for the slowest chain:
| Phase | What to check |
|---|---|
| Queued | Did the job wait for a runner? A faster command can't shorten a queue |
| Set up job, checkout | A container image pulled, a full clone where a shallow one would do |
| Toolchain and install | Did the cache hit, and did the hit save more than the restore took? |
| Build | Is the same output built in another job? |
| Tests | Is the runner's CPU busy all the time, or one worker doing everything? |
| Uploads | Artifacts and cache saves after the useful work is done |
Compare a warm run with a cold one. A cache that misses half the time looks fine in an average.
2. Skip what didn't need to run
- Cancel superseded runs. Every push after the first makes the run in progress pointless. A concurrency group with
cancel-in-progressstops it. Scope it to pull requests so a push to the default branch never cancels a deploy:
concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} - Run only what the change touches.
paths:filters skip a workflow on docs-only changes; in a monorepo,turbo run test --affectedor Nx's affected commands run only the packages that changed. A trigger-level filter means a required check never reports, so filter inside the job when the check is required.
3. Run independent jobs at once
GitHub runs jobs in parallel unless needs: chains them. Lint, type checks, unit tests and the build rarely need each other: start them together and let only packaging or deploy wait for all of them. If two jobs build the same thing, build it once and pass it with actions/upload-artifact, timing the upload and download.
4. Stop rebuilding the same state
This is where most of a long pipeline goes. Every job on a GitHub-hosted runner starts on a new VM, so it downloads its toolchain, installs dependencies, rebuilds Docker layers and compiles from scratch, every time.
- Caches that really hit.
actions/setup-node'scache: npm(and its pnpm and yarn forms) saves the package manager's downloads keyed on the lockfile; it doesn't savenode_modules, so the install still runs. GitHub keeps 10 GB per repository, drops entries unused for 7 days, and a hit is still a download onto the new VM (cache size limit). - Tool caches.
eslint --cache,tsc --incremental, Jest's cache directory and turbo's.turbo/cachesave the work itself, not the downloads. On a fresh VM they are empty unless you restore them too. - Docker layers.
docker buildon a fresh VM starts with no layers; exporting them with buildxcache-toand importing them each run is the workaround (Docker layer cache).
A machine that keeps its disk does all of this by itself: the caches, the tool caches, the layers and the build output are there from the last job.
5. Give the heavy steps more cores
Compilers, bundlers and test runners start one worker per core, so on 2 CPUs they run two at a time. Check what the test runner actually uses: Jest defaults to the cores minus one (one worker on 2 CPUs), pytest runs serially until -n auto, and Playwright defaults to half the cores, with its docs recommending one worker in CI. More cores only help once the runner is told to use them, and only on steps that keep the CPU busy; a download or a database start doesn't speed up.
On GitHub, more cores means larger runners: 4 to 96 cores, only on GitHub Team or Enterprise Cloud organizations, billed per minute at a higher rate per core.
6. Shard what is still long
A matrix of shards splits a long suite across jobs. It cuts the wall clock, not the minutes: each shard checks out, installs and builds again, so four shards pay the setup four times. Balance shards by measured time, not file count, and stop adding them when the setup is most of each shard.
What staying slow costs
A 15-minute pipeline that runs 40 times a day is 10 hours of waiting spread across your team, every day, before anyone counts the context switches. On per-minute billing, setup minutes are billed like test minutes:
| Minutes a month (4 vCPU) | GitHub | Skipbox S | You save each month |
|---|---|---|---|
| 10,000 | $102 | $39 | $63 |
| 20,000 | $222 | $39 | $183 |
| 30,000 | $342 | $39 | $303 |
More cores on GitHub cost more per minute, so the gap widens with the machine:
| 20,000 minutes a month | GitHub | Skipbox | You save each month |
|---|---|---|---|
| 8 vCPU | $422 | $79 (M) | $343 |
| 16 vCPU | $822 | $149 (L) | $673 |
GitHub's figures are after GitHub Team's included usage. Try your own minutes in the calculator on the home page.
When the fix is the machine
Real projects' own CI jobs, steps unchanged, on GitHub's public-repository runner (4 vCPU, 16 GB) and on a Skipbox own machine of the same vCPU, from our runs of their workflows:
| Project | GitHub | Skipbox, own machine | What the machine kept |
|---|---|---|---|
| astro (Node.js, Turborepo) | 528 s | 30 s | turbo's cache: 40 build tasks replayed |
| tauri (Rust) | 246 s | 53 s | Cargo's target/: no crate compiled again |
| ripgrep (Rust) | 200 s | 40 s | cross's target/ and Docker image |
| vite (Node.js) | 141 s | 100 s | package caches; the rest is its tests running |
A private repository's ubuntu-latest has half the cores of those GitHub runs.
What Skipbox does for your pipeline
Keeps the machine. Each plan comes with your account's own machine, kept between jobs. Docker images, layers and build cache, package caches, the tool cache, and your checkout with Cargo's target/ and turbo's cache stay on its disk: at least 7× GitHub's cache room, with no keys, no expiry after 7 days and no branch rule.
Adds cores at a flat price. A job picks its size by label:
| Label | Machine | Plan |
|---|---|---|
skipbox | 4 vCPU · 16 GB | S, $39 a month |
skipbox-8x16 | 8 vCPU · 16 GB | M, $79 a month |
skipbox-16x32 | 16 vCPU · 32 GB | L, $149 a month |
Minutes are unlimited, so shards, re-runs and a bigger matrix don't raise the bill. Skipbox jobs use no GitHub minutes, so a GitHub budget never stops them.
Looks after itself. Skipbox provisions the machine, keeps the image updated, cleans up after every job, and rebuilds a machine that fails its health check.
Moving is one line
jobs: test: runs-on: skipbox # was ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci && npm test - Sign in to Skipbox with GitHub.
- Install the Skipbox GitHub App and pick the repositories.
- Change
runs-onand push.
Your steps and actions run as they are, on Ubuntu 24.04 with Docker, Node.js and Python.
Security
- The machine is your account's alone. No other company's jobs run on it.
- After every job, the runner's credentials, processes and containers are wiped, and so are the git credentials and untracked files in the kept checkout. Clean every job gives each job a fresh machine instead.
- Code from people without write access (such as pull requests from forks) runs on a fresh VM that reads your caches but can't change them.
The details are in how jobs are isolated.
Try it on your slowest job
The trial is free for 7 days or 3,000 job-minutes, whichever comes first, and takes no card. Move the job on your critical path to runs-on: skipbox, run it twice, and read the second run's step timings beside GitHub's.
FAQ
Our GitHub Actions pipeline takes 25 minutes. What is the fastest way to make it faster?
Work through it in order. Read the run's graph and step times to find the chain of jobs that sets the time, with queue time apart. Cancel superseded runs and skip jobs whose files didn't change. Drop needs: that only express habit, so independent jobs run at once. Stop rebuilding state on every job: caches that really hit, or a runner that keeps its disk. Give CPU-bound steps more cores and let the test runner use them. Shard only what is still long. On a private repository's ubuntu-latest (2 CPUs, 8 GB), the last three are often most of the 25 minutes, and they are the machine's: Skipbox gives the job its own machine that keeps caches between jobs, with 4 to 16 vCPU.
Is switching to faster runners worth it compared to optimizing the workflow?
Do the free workflow fixes first: cancelling superseded runs, path filters and removing needless needs: cost nothing and no runner does them for you. After that, look at where the remaining time goes. Setup that repeats on every job (toolchains, dependency installs, Docker builds) and steps that keep every core busy are the machine's problem, and a runner that keeps its caches and has more cores removes them with a one-line runs-on change. Network waits and serial steps don't speed up with a bigger machine.
Why is GitHub Actions slower than my laptop?
Your laptop keeps its packages, tools, Docker layers and build output on disk, and usually has 8 or more cores. A private repository's ubuntu-latest job gets 2 CPUs and 8 GB, on a new VM that has none of that. Skipbox works like your laptop: the machine stays between jobs, with 4 vCPU or more.
Does sharding tests make GitHub Actions faster?
It shortens the wall clock, not the minutes. Each shard is a new job that checks out, installs and builds again, so four shards pay the setup four times, and per-minute billing counts every one. Shard after the setup is cheap and the runner's cores are in use. On Skipbox the setup stays on the disk and minutes are unlimited, so shards cost nothing extra.
How do I get more than 4 cores for a GitHub Actions job?
On GitHub, larger runners come in 4 to 96 cores, only for organizations on GitHub Team or Enterprise Cloud, billed per minute. On Skipbox, change runs-on to skipbox-8x16 for 8 vCPU or skipbox-16x32 for 16 vCPU, at a flat monthly price, on organizations and personal accounts alike.
How much does it cost to try?
Nothing. The trial is 7 days or 3,000 job-minutes, whichever comes first, with no card. Change your slowest job's runs-on to skipbox, run it twice, and compare its step timings.
I build Skipbox, the runner this guide ends on. Originally published at skipbox.dev/guides/speed-up-github-actions.
