# Running GitHub Actions on my own computer: what ubuntu-latest was quietly doing for me

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 `Dockerfile` on top of GitHub's `actions-runner` image. Every job gets a clean machine, like on GitHub.
* Every workflow uses `runs-on: ${{ vars.RUNNER || 'ubuntu-latest' }}`. Setting the repository variable `RUNNER` to `self-hosted` sends 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":

```text
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:

```dockerfile
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:

```text
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`:

```dockerfile
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:

```powershell
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:

```yaml
- 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 create` with a `.bicep` file downloads the Bicep CLI on first use. `az bicep install` in the image (as the `runner` user, so it lands in `~/.azure`).
* **Azure CLI extensions.** `az containerapp` installs its extension on first use. `az extension add --name containerapp` in the image.
* **Docker image builds.** `docker/setup-buildx-action` creates 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 the `docker` driver, which builds on Docker Desktop's own engine and keeps its layer cache between jobs. GitHub-hosted runs keep the `gha` cache:

```yaml
- 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-dotnet` and `setup-node` are cheap on `ubuntu-latest` because 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 the `setup-*` 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.

---

*Co-authored with Claude.*

If this was useful, [you can buy me a coffee](https://ko-fi.com/p47k0).

New articles: [follow via RSS](https://blog.koorevaar.com/feed.xml).
