Most write-ups of this topic walk through registering a runner against a real repository, which means embedding a screenshot of someone’s GitHub org or glossing over the token entirely. I did the part that’s actually infrastructure — provisioning the LXC, the security posture GitHub insists on, downloading and verifying the real current release — on my own Proxmox host, and I’m going to be upfront about the one step I stopped short of: live registration needs a fresh, repo-scoped token generated from that specific repo’s GitHub settings, and I didn’t want to burn one against a real project just to screenshot a green “Idle” dot.
The Container
pct create 995 local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
--hostname gha-runner --cores 1 --memory 512 --swap 256
--net0 name=eth0,bridge=vmbr0,ip=dhcp
--rootfs local-lvm:6
--unprivileged 1 --features nesting=1Small on purpose — a runner that just checks out a repo and runs a script doesn’t need much, and if a workflow ever does need Docker-in-Docker, nesting=1 is already there. Unprivileged, same as everything else on this host; there’s no reason a CI runner needs root on the hypervisor.
The Rule GitHub Actually Enforces, Not Just Recommends
GitHub’s own docs say not to run the runner as root, and unlike a lot of “best practice” advice, this one is enforced at the OS level by the setup script itself refusing to configure as root in some environments. Either way, it’s one command to do right from the start:
useradd -m -s /bin/bash runner
echo 'runner ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/runnerThe sudo line is a judgment call, not a requirement — only add it if your workflows actually need to install packages mid-job. A runner that only builds and tests a Node project doesn’t need it at all.
Getting the Real Current Version — Don’t Hardcode It
Every guide including this kind of post risks going stale the moment a new runner version ships. The actual fix is to never hardcode the version and always resolve it live:
curl -s https://api.github.com/repos/actions/runner/releases/latest
| grep -oP '"tag_name": "K[^"]+'When I ran this, it returned v2.336.0 — whatever it says when you run it is the one to use, not whatever number is printed above by the time you’re reading this.
The Checksum Assumption That Was Wrong
My first instinct was to fetch a predictable .sha256 sidecar file next to the tarball, the way plenty of other release pipelines work:
curl -L -o actions-runner.tar.gz.sha256
https://github.com/actions/runner/releases/download/v2.336.0/actions-runner-linux-x64-2.336.0.tar.gz.sha256
# → "Not Found"That URL doesn’t exist — GitHub doesn’t publish a separate checksum asset for this release. The actual checksum lives as inline text in the copy-paste block GitHub generates for you on the page where you add a new self-hosted runner to a specific repository (Settings → Actions → Runners → New self-hosted runner) — it’s regenerated per version and embedded directly in a echo "<hash> file.tar.gz" | shasum -a 256 -c line, not something you can fetch from a fixed URL ahead of time. Worth knowing before you go looking for a checksum file that isn’t there.
What Registration Actually Needs
./config.sh --helpconfirms the two required pieces for unattended setup:
--url string Repository to add the runner to. Required if unattended
--token string Registration token. Required if unattendedThe token is generated fresh from that repository’s settings page and expires roughly an hour later — it’s meant to be copy-pasted immediately, not stored. That’s the one step this post doesn’t screenshot: doing it for real means picking an actual repo and burning one of those tokens, and I’d rather say plainly that I stopped here than fabricate what the “Idle” runner list looks like.
What Comes After Registration (Not Tested Live, but Worth Knowing)
For anyone continuing past this point: ./svc.sh install && ./svc.sh start wraps the runner as a systemd service instead of the interactive ./run.sh, which is what you actually want on a box that isn’t attended. And ./config.sh remove --token <token> (a separate removal token, also generated from the repo settings page) is the clean way to deregister — deleting the LXC without running it first leaves a stale, offline runner listed against the repository until someone notices and removes it manually from the GitHub side.
Cleanup
pct stop 995 && pct destroy 995 --purge 1The container never held anything sensitive — no token was ever issued to it — so destroying it was just reclaiming the disk.
What’s Actually Proven Here
The provisioning and security setup is real and verified: the container, the non-root user, the live-resolved current version, and the correction on where the checksum actually lives. The registration and first-job-execution steps are the standard documented flow, described accurately, but not run against a live repository in this session — and I’d rather flag that boundary clearly than blur it.