Engineering Blog

Running GitHub Self-hosted Runners: From VMs to ARC

NAS and Kubernetes-based CI runner architecture AI-generated image; it is not a literal depiction of my production environment.

I started using self-hosted GitHub runners for one very simple reason: to spend less on GitHub Actions. It quickly became clear that cost was only part of the story. Disks fill up, image security updates can block builds, and reinstalling dependencies for every job quietly wastes both time and bandwidth.

This is a record of moving from a runner in a Mac container to a Linux VM on a NAS, and then to GitHub Actions Runner Controller (ARC) on k3s.

From a Mac container to a Linux VM

My first setup ran an AMD64 Linux image in a container on my Mac. It was a convenient way to get started, but the limits appeared as soon as CI needed Docker. Deciding how to handle Docker inside the container, how much access to grant through mounts and permissions, and how closely the environment matched a real Linux host all became recurring concerns.

I moved the runners to a Linux VM on my NAS and added three runners there. It was stable at first, but another problem soon surfaced. I had not included image cleanup in the workflow, so Docker images and build layers accumulated until the disk was nearly full.

That was the first important lesson: for a self-hosted runner, disk cleanup is part of CI. I added image cleanup and disk-usage checks, along with security scripts and more storage.

Image security should not first appear at deployment time

I also ran into occasional build failures when pushing Docker and Lambda container images to AWS. Security issues in outdated packages could stop a build just when I was trying to deploy. Discovering that only at deployment time turns a small update into an interruption while the base image is investigated and refreshed.

I now explicitly select the Trivy binary used during image creation and run the scan locally first. Lambda images are no longer a one-and-done artifact either: a scheduled Codex automation reviews and refreshes them regularly. Security scanning feels less like a final gate and more like maintaining an image lifecycle.

From long-lived runners to ARC

Long-running runners on a VM always need their capacity and state managed. That is why I moved to GitHub ARC. ARC creates an ephemeral runner Pod in Kubernetes for an Actions job and discards it when the job finishes. Build remnants no longer accumulate indefinitely in the next runner environment, making capacity management much simpler.

I installed k3s, a lightweight Kubernetes distribution, in a Linux VM on the NAS. It is possible to operate the runner farm with Docker alone, but I wanted to keep Docker-in-Docker (DinD) separate from other workloads. A dedicated VM with k3s also makes it easier to isolate runner permissions and build environments from operational services.

ARC is not a free solution to every problem, though. Its runners are clean for each job, but repeatedly downloading the same dependencies increases both job time and network usage.

Reducing overhead with caches and prebuilt images

After moving to ARC, I separated caches through PersistentVolumeClaims (PVCs). Playwright browsers, Python packages, and npm/pnpm caches live on persistent volumes and are mounted by runner Pods for reuse. The runner toolchain is baked into the runner image as well.

I also switched from rebuilding the runner and base images on every CI run to building them on a schedule. Jobs no longer wait for package updates or dnf update to finish. A private registry on the NAS VM keeps commonly used images close to the runners.

The responsibilities now look like this:

  • ARC disposes of runners after jobs, keeping execution environments clean.
  • PVC-backed caches reuse large tools and packages that would otherwise be downloaded for every build.
  • Prebuilt images and a local registry move update and image-pull costs out of the job’s critical path.

Cache sharing needs some care. Cache keys should follow the relevant package manager and lockfile, otherwise an old dependency can quietly contaminate a new build.

The practical choice between ARM and AMD64

I use AWS Graviton (ARM) instances because they are less expensive. But cross-building AMD64 images on a lower-power CPU takes time, especially when image builds are part of the same path.

I use the Kubernetes environment available through Docker Desktop on my MacBook for the heavy image-publishing work. As a solo developer, it is unlikely that I would deploy while the MacBook is off. That lets the NAS’s x86 Linux VM focus on ordinary CI runner operations while the Mac handles expensive image builds.

It is not a perfect automation story; it is a practical way to move the current bottleneck to the least expensive place. When working across ARM and AMD64, though, it is still essential to verify an image on the architecture where it will actually run.

Closing thoughts

If I were to keep operating self-hosted runners, ARC would be my default over maintaining several long-lived runners by hand. Being able to discard an environment after each job makes state and disk capacity much easier to manage.

That does not mean ARC is effortless. PVC caches, runner images, base-image refreshes, a private registry, and security scans all become operational responsibilities. For a small team with modest CI usage, paying for GitHub-hosted runners may still be cheaper—and certainly simpler.

In the end, self-hosted runners are both a way to save compute costs and a decision about where to spend operating time. Sometimes I still think, “Wouldn’t it be best to just pay for this?” But the process has taught me much more about how build environments are assembled, where they slow down, and what accumulates over time.


Note: I’m a solo developer based in Korea. I use AI to help translate my thoughts into English to share my journey with a global audience.

Join the Investigation

Get the latest updates on my projects and indie hacking journey directly in your inbox.

No spam. Unsubscribe anytime.