gh-runner-01 (GitHub Actions runner host)
gh-runner-01 is a Proxmox VM that hosts self-hosted GitHub Actions runners on the homelab LAN, so workflows can reach the private 192.168.1.x network (the k3s cluster and Home Assistant) that GitHub-hosted runners cannot. One VM hosts multiple runner instances — each its own systemd service — registered on demand.
| Property | Value |
|---|---|
| vmid / node | 9001 / pve01 |
| IP (static, cloud-init) | 192.168.1.6/24, gw 192.168.1.1 |
| CPU / RAM / disk | 2 vCPU / 4 GB / 40 GB on local-lvm |
| OS | Debian 12 (bookworm) cloud image, vmbr0 |
The VM is provisioned as IaC by a short bash qm script run on the Proxmox host; the runner software and each runner registration are set up on the guest afterwards.
Prerequisites
- SSH access as root to
pve01— the provision script is streamed there over SSH (bash -s) and drivesqm; nothing is copied onto the host. - The Debian 12 cloud image already in the Proxmox store at
/var/lib/vz/template/iso/debian-12-genericcloud-amd64.qcow2(fetch it once, below). The provision script imports it; it does not download. - The public keys to inject placed in
/root/.ssh/authorized_keysonpve01.
Fetch the cloud image (once)
Run this first: it checks pve01's store for the Debian 12 cloud image and downloads it only if missing. Every VM reuses the image, so the provision script itself never downloads.
ssh root@192.168.1.4 'bash -s' < fetch-cloud-image.sh
#!/usr/bin/env bash
# Ensure the Debian 12 cloud image is in the Proxmox store. Run first, as root on
# pve01, before provisioning. Downloads only if the image is not already there.
set -euo pipefail
IMG=/var/lib/vz/template/iso/debian-12-genericcloud-amd64.qcow2
URL=https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2
if [ -f "$IMG" ]; then
echo "Image already present: $IMG"
exit 0
fi
echo "Downloading $URL"
curl -fsSL -o "$IMG" "$URL"
echo "Fetched: $IMG"
All three scripts live in this folder and are embedded below; run them by streaming over SSH from stdin so nothing is copied onto the host or VM (ssh … 'bash -s' < script). Because the Debian cloud image gives its debian user passwordless sudo, the sudo bash -s forms work non-interactively.
Provision the VM
Streams the script to pve01 as root: it imports the Debian 12 cloud image from the store, creates VMID 9001 with the runner spec (2 vCPU / 4 GB / 40 GB, static 192.168.1.6), and starts it.
ssh root@192.168.1.4 'bash -s' < provision-gh-runner.sh
#!/usr/bin/env bash
# Create gh-runner-01 on this Proxmox host. Run as root on pve01.
set -euo pipefail
VMID=9001
IMG=/var/lib/vz/template/iso/debian-12-genericcloud-amd64.qcow2
# Idempotent: if the VM already exists, do nothing and succeed.
qm status "$VMID" >/dev/null 2>&1 && { echo "VM $VMID already exists -- nothing to do."; exit 0; }
# Fail before touching qm if the keys we inject are missing.
[ -f /root/.ssh/authorized_keys ] || { echo "Put your public key(s) in /root/.ssh/authorized_keys first." >&2; exit 1; }
# Use the cloud image already in the Proxmox store -- fetch it once beforehand
# (see gh-runner.md), don't download here.
[ -f "$IMG" ] || { echo "Cloud image not in the store: $IMG -- fetch it first (see gh-runner.md)." >&2; exit 1; }
qm create $VMID --name gh-runner-01 --memory 4096 --cores 2 --cpu host --net0 virtio,bridge=vmbr0 --scsihw virtio-scsi-single
qm set $VMID --scsi0 local-lvm:0,import-from=$IMG --ide2 local-lvm:cloudinit --boot order=scsi0 --serial0 socket --vga serial0
qm set $VMID --ciuser debian --ipconfig0 ip=192.168.1.6/24,gw=192.168.1.1 --sshkeys /root/.ssh/authorized_keys --agent enabled=1 --onboot 1
qm disk resize $VMID scsi0 40G
qm start $VMID
Prerequisites and re-running
The --sshkeys line injects /root/.ssh/authorized_keys from pve01 — make sure your public key is there first (the script fails fast if it is missing). The script is idempotent: if VMID 9001 already exists it prints a message and exits without changes. To rebuild, qm destroy 9001 first.
Install the guest agent
SSH to the VM at its static ipconfig0 address (192.168.1.6) and install the QEMU guest agent. This is a generic VM step — reuse the same script for any VM — kept separate from the GitHub-runner setup.
ssh debian@192.168.1.6 'sudo bash -s' < install-qemu-agent.sh
#!/usr/bin/env bash
# Install + enable the QEMU guest agent in a Debian/Ubuntu VM, so Proxmox can
# read the VM's IP and do graceful shutdown. Generic and reusable for ANY VM --
# not specific to the runner host. Run on the VM (via sudo).
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends qemu-guest-agent
sudo systemctl enable --now qemu-guest-agent
echo "qemu-guest-agent installed and running."
Why the VM shows \"Guest Agent not running\" until now
--agent enabled=1 only opens the agent channel on the Proxmox side; the Debian cloud image has no guest daemon until this step installs it. The VM is reachable the whole time at its static 192.168.1.6, which is why you can SSH in to run this before the agent is up.
Find the VM's IP
With the guest agent installed, Proxmox now reports the VM's IP and this reads it back over SSH:
ssh root@192.168.1.4 'bash -s' < get-vm-ip.sh
#!/usr/bin/env bash
# Print a VM's IPv4 address(es) via the QEMU guest agent. Run as root on pve01.
# Usage: ./get-vm-ip.sh [VMID] (default 9001)
#
# Needs qemu-guest-agent running in the guest (install-qemu-agent.sh installs it).
# If the agent is not up yet, Proxmox likewise shows "Guest Agent not running" --
# the VM is still reachable at the static IP set by cloud-init (ipconfig0).
set -euo pipefail
VMID="${1:-9001}"
if ! qm agent "$VMID" ping >/dev/null 2>&1; then
echo "Guest agent not responding on VM $VMID." >&2
echo "Install/start qemu-guest-agent in the guest first (install-qemu-agent.sh does this)." >&2
echo "Meanwhile the VM uses its static ipconfig0 address: $(qm config "$VMID" | grep -oP 'ipconfig0:.*ip=\K[0-9.]+' || echo '?')" >&2
exit 1
fi
qm agent "$VMID" network-get-interfaces \
| grep -oP '"ip-address"\s*:\s*"\K[0-9]+(\.[0-9]+){3}' \
| grep -v '^127\.'
Is 192.168.1.6 free?
ipconfig0 sets the address statically, so a clash with an existing host is a silent conflict, not a DHCP rejection. Confirm from a LAN machine: ping 192.168.1.6 then arp -n 192.168.1.6 — the MAC must match the VM's net0 (qm config 9001 | grep net0). A different or duplicated MAC means the IP is taken; pick another and re-run provisioning.
Set up the runner host (once)
Streams the setup to the VM: installs the runner prerequisites, creates a non-login github-runner service user, and lays down a pristine runner distribution that each runner instance is cloned from.
ssh debian@192.168.1.6 'sudo bash -s' < setup-runner.sh
#!/usr/bin/env bash
#
# setup-runner.sh -- one-time preparation of the gh-runner-01 host.
#
# Installs the GitHub Actions runner prerequisites, creates a dedicated
# non-login service user, and lays down a pristine copy of the runner
# distribution that register-runner.sh clones per runner instance. Run ONCE
# per VM, as root. Re-running is safe (idempotent).
#
# Usage: sudo ./setup-runner.sh [RUNNER_VERSION]
# RUNNER_VERSION optional, e.g. 2.319.1. Defaults to the latest release.
#
set -euo pipefail
BASE=/opt/actions-runner
DIST="$BASE/_dist"
RUNNER_USER=github-runner
ARCH=x64 # the VM is amd64
if [ "$(id -u)" -ne 0 ]; then
echo "Run as root (sudo)." >&2
exit 1
fi
# --- resolve the runner version -----------------------------------------------
version="${1:-}"
if [ -z "$version" ]; then
echo "Resolving latest actions-runner release..."
version="$(curl -fsSL https://api.github.com/repos/actions/runner/releases/latest \
| grep -oP '"tag_name":\s*"v\K[^"]+')"
fi
[ -n "$version" ] || { echo "Could not determine runner version." >&2; exit 1; }
echo "Runner version: $version"
# --- deps + service user ------------------------------------------------------
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y --no-install-recommends curl git ca-certificates tar
if ! id "$RUNNER_USER" >/dev/null 2>&1; then
echo "Creating service user $RUNNER_USER"
useradd --system --create-home --shell /usr/sbin/nologin "$RUNNER_USER"
fi
# --- lay down the pristine runner distribution --------------------------------
mkdir -p "$DIST"
tarball="actions-runner-linux-${ARCH}-${version}.tar.gz"
if [ ! -f "$DIST/config.sh" ]; then
echo "Downloading $tarball"
curl -fsSL -o "/tmp/$tarball" \
"https://github.com/actions/runner/releases/download/v${version}/${tarball}"
tar -xzf "/tmp/$tarball" -C "$DIST"
rm -f "/tmp/$tarball"
else
echo "Runner distribution already present at $DIST -- skipping download."
fi
# .NET runtime prerequisites (libicu etc.). Run every time -- it is idempotent,
# so a partial first run (deps failed after the tarball extracted) self-heals on
# re-run instead of being skipped by the download guard above.
"$DIST/bin/installdependencies.sh"
chown -R "$RUNNER_USER:$RUNNER_USER" "$BASE"
echo
echo "Runner host ready. Register one or more runners with:"
echo " sudo ./register-runner.sh <name> <url> <registration-token> [labels]"
Register runners
Each runner needs a short-lived registration token from GitHub (repo or org → Settings → Actions → Runners → New self-hosted runner). Tokens are single-use, so grab a fresh one per runner. To add another runner, re-run with a different name and a fresh token — each becomes its own systemd service, side by side.
ssh debian@192.168.1.6 'sudo bash -s -- <NAME> <REPO_URL> <TOKEN> <LABELS>' < register-runner.sh
Fill in the four positional arguments:
<NAME>— unique runner name, e.g.gh-runner-01-a<REPO_URL>— e.g.https://github.com/Fax-Me-In-The-Cloud/homelab-pages<TOKEN>— the single-use registration token from GitHub<LABELS>— comma-separated, e.g.homelab,deploy
#!/usr/bin/env bash
#
# register-runner.sh -- register ONE GitHub Actions runner instance on this host
# and install it as a systemd service. Run once per runner, as root. Because the
# host keeps a pristine copy of the runner (setup-runner.sh), you can host many
# runners side by side: just re-run this with a different <name> and a fresh
# registration token.
#
# Usage: sudo ./register-runner.sh <name> <url> <registration-token> [labels]
# name unique runner name, e.g. gh-runner-01-a
# url repo or org URL, e.g. https://github.com/Fax-Me-In-The-Cloud/homelab-pages
# token short-lived registration token (Settings -> Actions -> Runners ->
# New self-hosted runner). Single-use -- one fresh token per runner.
# labels optional extra labels (comma-separated), e.g. homelab,deploy
#
set -euo pipefail
BASE=/opt/actions-runner
DIST="$BASE/_dist"
RUNNER_USER=github-runner
if [ "$(id -u)" -ne 0 ]; then
echo "Run as root (sudo)." >&2
exit 1
fi
name="${1:-}"; url="${2:-}"; token="${3:-}"; labels="${4:-}"
if [ -z "$name" ] || [ -z "$url" ] || [ -z "$token" ]; then
echo "Usage: sudo ./register-runner.sh <name> <url> <registration-token> [labels]" >&2
exit 1
fi
if [ ! -x "$DIST/config.sh" ]; then
echo "Runner distribution missing at $DIST. Run setup-runner.sh first." >&2
exit 1
fi
dir="$BASE/$name"
if [ -e "$dir" ]; then
echo "Runner directory already exists: $dir (remove it or pick another name)." >&2
exit 1
fi
echo "Creating runner instance at $dir"
cp -a "$DIST" "$dir"
chown -R "$RUNNER_USER:$RUNNER_USER" "$dir"
# Configure as the service user (never as root). --replace lets a re-registered
# name take over cleanly; --unattended avoids prompts.
config_args=(--unattended --url "$url" --token "$token" --name "$name" --work _work --replace)
[ -n "$labels" ] && config_args+=(--labels "$labels")
sudo -u "$RUNNER_USER" bash -c "cd '$dir' && ./config.sh $(printf '%q ' "${config_args[@]}")"
# Install + start the systemd service (svc.sh must run as root; it runs the
# runner process as $RUNNER_USER).
( cd "$dir" && ./svc.sh install "$RUNNER_USER" && ./svc.sh start )
echo
echo "Runner '$name' registered and started. Check it with:"
echo " cd $dir && ./svc.sh status"
Keep the registration token off the wire
Tokens are single-use and expire in ~1 h, so exposure is bounded. Still, the token above lands in your local shell history and is briefly visible in the VM's process table while config.sh runs; clear it from history (set +o history) and let it expire once the runner shows online.
Verify
# Each runner's service is active:
ssh debian@192.168.1.6 'systemctl list-units "actions.runner.*" --no-pager'
# And appears "Idle" under Settings -> Actions -> Runners in GitHub.
Look for one actions.runner.*.service per registered runner, all active (running).
Remove a runner
Stops and uninstalls the service, deregisters from GitHub, and deletes the directory. The removal token (from the runner's … menu → Remove on the Runners page) is optional:
# Clean removal (deregisters from GitHub):
ssh debian@192.168.1.6 'sudo bash -s -- <NAME> <REMOVAL_TOKEN>' < remove-runner.sh
# Failed runner, no token -- tear it down locally (leaves it "offline" in GitHub
# until you remove it from the Runners page by hand):
ssh debian@192.168.1.6 'sudo bash -s -- <NAME>' < remove-runner.sh
#!/usr/bin/env bash
# Remove ONE GitHub Actions runner instance from this host: stop + uninstall its
# service, deregister it from GitHub (if a token is given), and delete its
# directory. Run as root on the VM.
# Usage: sudo ./remove-runner.sh <name> [removal-token]
# removal-token: OPTIONAL. From GitHub, the same Runners page as registration
# (the runner's "..." menu -> Remove gives the token). Omit it to
# just tear the runner down locally -- e.g. a failed runner you
# can't get a token for; it will linger as "offline" in GitHub
# until you remove it from the Runners page by hand.
set -euo pipefail
BASE=/opt/actions-runner
RUNNER_USER=github-runner
if [ "$(id -u)" -ne 0 ]; then
echo "Run as root (sudo)." >&2
exit 1
fi
name="${1:-}"; token="${2:-}"
if [ -z "$name" ]; then
echo "Usage: sudo ./remove-runner.sh <name> [removal-token]" >&2
exit 1
fi
dir="$BASE/$name"
if [ ! -d "$dir" ]; then
echo "No such runner: $dir" >&2
exit 1
fi
cd "$dir"
./svc.sh stop || true
./svc.sh uninstall || true
if [ -n "$token" ]; then
sudo -u "$RUNNER_USER" ./config.sh remove --token "$token"
else
echo "No token given -- skipping GitHub deregistration. Remove '$name' from the" >&2
echo "Runners page by hand if it lingers as offline." >&2
fi
cd /
rm -rf "$dir"
echo "Runner '$name' removed."
Troubleshooting
installdependencies.shfails — the runner needslibicu; on Debian 12 it is pulled in by that script. Re-runsudo /opt/actions-runner/_dist/bin/installdependencies.shaftersudo apt-get update.- Runner shows offline right after registration — check the service:
cd /opt/actions-runner/<name> && sudo ./svc.sh statusand its journal (journalctl -u actions.runner.*). - DNS timeouts from jobs — the VM resolves via the LAN Pi-holes → Unbound (see Unbound); a job that can't reach
github.comusually means/etc/resolv.confisn't pointing at192.168.1.60/.21.