Skip to content

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 drives qm; 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_keys on pve01.

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
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
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
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
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
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
register-runner.sh
#!/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
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.sh fails — the runner needs libicu; on Debian 12 it is pulled in by that script. Re-run sudo /opt/actions-runner/_dist/bin/installdependencies.sh after sudo apt-get update.
  • Runner shows offline right after registration — check the service: cd /opt/actions-runner/<name> && sudo ./svc.sh status and 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.com usually means /etc/resolv.conf isn't pointing at 192.168.1.60/.21.