Running my GitHub Actions on my own hardware
Every time a workflow runs on GitHub-hosted runners, you're using minutes out of a plan whether the workflow did anything useful or not. The free tier is generous if you're running a couple of small jobs a week. It runs out fast if you have a project that triggers builds on every commit, or that has matrix runs across multiple platforms, or that you've nudged into running on every pull request from external contributors.
There's an official answer to running those jobs on your own hardware. The Actions Runner Controller, which everyone calls ARC. It's a Kubernetes operator that spins up GitHub Actions runner pods on demand. A workflow triggers, a pod starts, the pod registers with GitHub, GitHub hands it a job, the pod runs the job, then the pod exits. You pay nothing because the pod runs on your cluster, on your hardware, on your network.
ARC has a v1 and a v2. The v2 rewrite is the current direction and is what GitHub officially supports. Most blog posts you'll find online are about v1, and the v2 architecture is meaningfully different. I'm going to talk about v2 only. If you're reading older content, the deployment patterns are going to look strange because they were.
#What v2 actually is
In v2, ARC ships as two Helm charts. The first one (gha-runner-scale-set-controller) is the operator. It runs in a namespace called arc-systems by convention, watches the cluster, and reacts to events. You install this once, ever, and largely forget about it.
The second chart (gha-runner-scale-set) is the scale set. You install one of these per place-you-want-runners-attached-to. If you have three repos that should each have their own runner pool, you install three scale sets. Each one has a name, an owner (a GitHub username or organization), a target (a specific repo, a whole org, or an enterprise), and a credential.
Each scale set creates a listener pod in your cluster. The listener pod is the thing that maintains an open connection to GitHub and waits for job-needs-a-runner events. When one fires, the listener tells ARC: "spin up a runner pod with this configuration." ARC creates the pod. The pod registers itself with GitHub, picks up the job, runs it, and gets deleted when done.
The scale set is more or less ephemeral. It owns no long-running runners. It owns one listener. The runners come and go.
#The credential question
GitHub doesn't let arbitrary pods just sign in and ask for jobs. Each scale set needs a credential that GitHub will accept. There are two paths.
A personal access token (PAT) is the easy path. You generate a token on GitHub with the right scopes, put it in a Kubernetes Secret, point the scale set at the Secret. The token belongs to your account, which means workflows run as you, see anything you can see, and consume any access you have. For a personal homelab pointing at your own repos, this is fine. For anything organizational, no.
A GitHub App is the correct path. You create a GitHub App, give it minimal permissions (basically: read repository contents and write check statuses), install the app on the repos or org that should get runners, and use the app's credentials (an app ID, an installation ID, and a private key) instead of a token. Slightly more setup, considerably more correct security posture, because the app's blast radius is exactly what you scoped it to and nothing else.
I run a PAT because the only thing using these runners is one of my own open-source projects on my own GitHub account, and the math works out. If I had collaborators, or multiple projects sharing one runner pool, I'd switch to a GitHub App. Not yet.
#A worked example
The chart for the scale set looks roughly like this once configured:
# helm install --namespace arc-runners --create-namespace \
# --values values.yaml my-runners gha-runner-scale-set
githubConfigUrl: https://github.com/your-username/your-repo
githubConfigSecret: github-pat
runnerScaleSetName: my-runners
template:
spec:
containers:
- name: runner
image: ghcr.io/actions/actions-runner:latest
command: ["/home/runner/run.sh"]
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
cpu: 2
memory: 4Gi
Two things worth flagging. The github-pat Secret is created separately and contains a single key (github_token) with the PAT value. The PAT is the thing I'd manage via external-secrets if you've read the other post on Bitwarden Secrets Manager; for the example, I'm showing the simpler form.
And the template.spec is just a normal Pod spec. ARC takes whatever you put there and uses it as the template for every runner pod it spawns. You can mount volumes, set environment variables, add init containers, change the runner image to a custom one with extra tools baked in. The runner image you see referenced (ghcr.io/actions/actions-runner:latest) is the stock GitHub image with basically nothing in it but the runner agent. For workflows that need Docker, or Go, or a specific Python version, you either layer those into a custom image or install them in the workflow's own setup steps.
Once helm install finishes, you wait about a minute. The listener pod appears in the namespace. The scale set registers with GitHub. From the repo's settings under Actions → Runners, you should see "my-runners" listed.
To actually use the runner in a workflow, change runs-on: in your workflow YAML from ubuntu-latest to your scale-set name:
jobs:
build:
runs-on: my-runners
steps:
- uses: actions/checkout@v4
- run: echo "this runs on my hardware"
Push that to your repo. Next time the workflow fires, GitHub looks for a runner matching my-runners, finds your scale set, hands it a job. Your cluster spawns a runner pod. The pod runs the job. The pod gets deleted.
#What broke along the way
Two things bit me. The first is the DNS thing I wrote a whole separate post about. The ARC controller pod inherited my host's search domain, tried to resolve github.com, got back the wrong IP, and silently failed to register the scale set. Symptoms: scale set "exists" in Helm, listener pod logs are quiet, GitHub never shows the runner. The fix was the dnsPolicy: None and dnsConfig block I've now applied to about a dozen workloads.
The second was self-inflicted. My PAT had the wrong scopes the first time I generated it. ARC needs repo, workflow, and read:org at minimum, depending on whether you're targeting a repo or an org. The listener pod's logs were helpful here ("unauthorized" with a status code), but I'd skipped past them initially because I didn't expect the credential to be the problem. Always check the credential's permissions match what the docs say; "I gave it all the obvious scopes" turns out to be different from "I gave it all the required scopes."
#What this saves me
The cluster runs the runner pods, so I pay nothing in GitHub minutes. The runner pods spawn within about ten seconds, run, and exit. The cluster's node load goes up briefly while a workflow is running and goes back to nothing afterward. From my perspective the only visible change is that the runner shows up as "self-hosted" in the workflow logs instead of "ubuntu-latest."
The non-obvious benefit is that the runner has access to my LAN. If a workflow needs to talk to something inside my home network (a local registry, a cluster I'm deploying to, a database it's testing against), it can. With GitHub-hosted runners, those services would have to be exposed to the internet for the workflow to reach them. With self-hosted runners, the workflow is already inside the network. That's the kind of capability that quietly justifies the whole exercise, even before counting any of the cost savings.
I would not run ARC in production at a company without thinking carefully about runner isolation, secret leakage in jobs, and what happens when a malicious actor opens a pull request with a workflow that does something interesting. For a homelab attached to one of my own repositories, those concerns are real but small. For an organization, they're the entire reason the GitHub App auth path exists.
Either way, the docs are scattered enough that having this in one place was reason enough to write it down.