I had about 30 GitHub Actions minutes left for the month, so I moved my workflows to a runner on my own laptop. The first deploy on it failed. After fixing that, it was more than twice as slow as on GitHub. After a few more changes it was faster than GitHub ever was.
The workflows were written for runs-on: ubuntu-latest, and GitHub's hosted image does a lot of work you never see. This post is about that hidden work, and how to give it back to your own runner.
The setup
- A Windows laptop with Docker Desktop.
- A PowerShell script starts a fresh runner container for every job (a just-in-time runner), built from my own
Dockerfileon top of GitHub'sactions-runnerimage. Every job gets a clean machine, like on GitHub. - Every workflow uses
runs-on: ${{ vars.RUNNER || 'ubuntu-latest' }}. Setting the repository variableRUNNERtoself-hostedsends jobs to my laptop; deleting it sends them back to GitHub.
That clean machine per job is the whole story. A hosted runner is also clean, but it comes with the .NET SDK, Node, the Azure CLI, Bicep and a warm tool cache. My container came with almost nothing, and remembered nothing between jobs.
Failure 1: the action download timed out
The first run of my Azure Functions deploy did not get past "Set up job":
Failed to download action 'https://codeload.github.com/Azure/functions-action/tar.gz/c5060b3...'.
Error: The request was canceled due to the configured HttpClient.Timeout of 100 seconds
Before a job starts, the runner downloads every action it uses. Azure/functions-action is huge: I stopped a test download at 88 MB after 5 minutes, still not complete. The runner gives up after 100 seconds, retries, gives up again. A hosted runner has a fast connection to GitHub; my home connection does not, and a fresh container downloads all of it again for every job.
The fix: the runner checks a local folder before it downloads anything. Fill it when the image is built:
ENV ACTIONS_RUNNER_ACTION_ARCHIVE_CACHE=/home/runner/action-archive-cache
RUN set -eu; \
for action in Azure/functions-action@v1 actions/checkout@v4 azure/login@v2; do \
repo=${action%@*}; tag=${action#*@}; \
refs=$(git ls-remote "https://github.com/$repo" "refs/tags/$tag" "refs/tags/$tag^{}"); \
sha=$(echo "$refs" | awk '/\^\{\}$/ {print $1; found=1} END {if (!found) exit 1}' \
|| echo "$refs" | awk 'NR==1 {print $1}'); \
dir="$ACTIONS_RUNNER_ACTION_ARCHIVE_CACHE/$(echo "$repo" | tr / _)"; \
mkdir -p "$dir"; \
curl -fsSL --retry 5 -o "$dir/$sha.tar.gz" "https://codeload.github.com/$repo/tar.gz/$sha"; \
done
The file name has to be the commit SHA the tag points to (<owner>_<repo>/<sha>.tar.gz), hence the git ls-remote. The image build has no 100-second limit, so a slow connection only makes the build slower, once. Now the same 88+ MB action is unpacked from disk in about 5 seconds.
If a tag moves later (a new v1 release), the cache simply misses and the runner downloads it as before. A docker build --no-cache refreshes it.
Failure 2: setup-dotnet could not install
The next run got one step further:
Error: Failed to install dotnet, exit code: 1.
mkdir: cannot create directory '/usr/share/dotnet': Permission denied
actions/setup-dotnet installs into /usr/share/dotnet, where ubuntu-latest has it. In the runner image that folder belongs to root, and jobs run as the runner user. On GitHub this step usually finds the SDK already installed and does nothing.
The fix is to do what the hosted image does: install the SDK in the image, in the same place, and hand it to runner:
ENV DOTNET_ROOT=/usr/share/dotnet
ENV PATH=$DOTNET_ROOT:$PATH
RUN curl -fsSL -o /tmp/dotnet-install.sh https://dot.net/v1/dotnet-install.sh \
&& bash /tmp/dotnet-install.sh --channel 10.0 --install-dir "$DOTNET_ROOT" \
&& chown -R runner:runner "$DOTNET_ROOT" \
&& dotnet --version
The dotnet --version at the end makes a broken install fail the image build, not some job later.
Green, but 4:20 instead of 2:00
The third run passed, in 4 minutes 20 seconds. On GitHub the same job took about 2 minutes. Almost all of the difference was one step:
| Step | GitHub-hosted | My runner, first green run |
|---|---|---|
| Build and publish | 17 to 25 s | 2:59 |
The log showed why: the build itself took about 6 seconds. The rest was NuGet:
- 46 s restoring the Function's packages;
- 2:07 restoring a second project,
WorkerExtensions, which the Azure Functions SDK generates and restores during the build.
Every job started with an empty NuGet cache and downloaded about 190 packages.
Why not bake the packages into the image?
That was my first idea, and I tested it. A dotnet restore of just the .csproj does not touch the WorkerExtensions packages at all. A full dotnet publish with a one-line stub Program.cs got most of them, but the real build still needed 6 more packages: the SDK picks some of them based on the actual code. And baked-in packages would need an image rebuild after every version bump.
So NuGet gets a named Docker volume instead, mounted into every runner container:
docker run --rm ... -v blog-runner-nuget:/home/runner/.nuget/packages ...
The first run fills it; every later run restores from local disk. It always matches the current project, without rebuilding anything. One detail: create the folder in the image (mkdir -p /home/runner/.nuget/packages). A new named volume copies its owner from the image, so the volume belongs to runner and not to root.
Next run: restore took 160 ms instead of 2:53, and the whole job took 1:38.
The last 10 seconds
setup-dotnet still took 10 seconds. It found the SDK in the image, but only after two online checks of about 5 seconds each. On my own runner the image already guarantees the SDK, so the step can go:
- name: Setup .NET
if: vars.RUNNER != 'self-hosted'
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
GitHub-hosted runs keep the step. Result: 1:28.
The numbers
Deploy of an Azure Function (.NET 10), job duration from "Set up job" to the end:
| Step | GitHub-hosted | First green run | Cached | Final |
|---|---|---|---|---|
| Set up job | 7 to 13 s | 10 s | 9 s | 10 s |
| Checkout | 1 s | 4 s | 5 s | 3 s |
| Setup .NET | 1 to 10 s | 10 s | 10 s | skipped |
| Build and publish | 17 to 25 s | 2:59 | 11 s | 11 s |
| Azure login | 5 to 9 s | 9 s | 8 s | 9 s |
| Deploy to Azure Functions | 71 to 78 s | 35 s | 40 s | 42 s |
| Total | about 2:00 | 4:20 | 1:38 | 1:28 |
Two things I did not expect:
- The deploy step itself is almost twice as fast from my laptop (35 to 42 s against 71 to 78 s). I did not dig into why.
- Queue time does not change. From "job created" to "job started" takes 13 to 17 seconds on my runner and about 10 on GitHub's, even with my runner already idle and waiting. That is GitHub handing out the job, not the container starting. I left it out of the table, because neither setup can change it.
Same problem, different project: Node
In another project, a Node app with unit tests and Playwright browser tests, I saw the same pattern. Every job ran setup-node, which downloaded Node again in every fresh container. After moving Node and the npm cache into the runner image:
| Step | Before | After |
|---|---|---|
| Set up job | 10 to 14 s | 2 s |
| checkout | 5 to 7 s | 2 s |
| setup-node | 31 to 42 s | 0 to 1 s |
| npm ci | 3 to 4 s | 6 to 7 s |
| Before the real work starts | about 55 to 65 s | about 10 s |
npm ci got 3 seconds slower, because it now really installs from the cache in the image instead of the old path. That is a fair trade for 30+ seconds less setup-node. Every job in that project is now about 50 seconds faster; the test job takes about 21 seconds and the browser job about 30.
Other things that go in the image
The same reasoning, for tools my other workflows download on every run:
- Bicep.
az deployment group createwith a.bicepfile downloads the Bicep CLI on first use.az bicep installin the image (as therunneruser, so it lands in~/.azure). - Azure CLI extensions.
az containerappinstalls its extension on first use.az extension add --name containerappin the image. - Docker image builds.
docker/setup-buildx-actioncreates a new builder container per job by default, so every build pulls the base images and rebuilds every layer. On my own runner I use thedockerdriver, which builds on Docker Desktop's own engine and keeps its layer cache between jobs. GitHub-hosted runs keep theghacache:
- uses: docker/setup-buildx-action@v3
with:
driver: ${{ vars.RUNNER == 'self-hosted' && 'docker' || 'docker-container' }}
- uses: docker/build-push-action@v6
with:
cache-from: ${{ vars.RUNNER != 'self-hosted' && 'type=gha' || '' }}
cache-to: ${{ vars.RUNNER != 'self-hosted' && 'type=gha,mode=max' || '' }}
I have not timed that workflow on the new image yet.
Take aways
- A hosted runner is not an empty machine.
setup-dotnetandsetup-nodeare cheap onubuntu-latestbecause the tool is usually already there. On a fresh container per job, they download everything, every time. - Put tools that rarely change in the image: actions (
ACTIONS_RUNNER_ACTION_ARCHIVE_CACHE), SDKs and runtimes in the paths thesetup-*actions expect, CLI plugins. - Put packages that change with the code in a volume: NuGet, npm. They stay current without rebuilding the image, and the first run fills them.
- Use the host's Docker for image builds, so its layer cache survives between jobs.
- Skip
setup-*steps on your own runner when the image already guarantees the tool. - Measure per step, from the job logs. Every fix here came from a step timing or a log line, not from guessing.
The result: the deploy went from 2:00 on GitHub to 1:28 on my laptop, costs no Actions minutes, and runs as often as I like.